From 794b1a5c1fffe0011cd2f330dc33b67e2359fdf4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 6 Jul 2026 22:35:55 +0200 Subject: [PATCH 001/150] Fix JavaScript docs syntax highlighting --- CHANGELOG.md | 4 ++-- README.md | 2 +- guides/features/css-modules.md | 2 +- guides/features/frameworks.md | 4 ++-- guides/features/glob-imports.md | 2 +- guides/features/testing.md | 24 ++++++++++++------------ 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3631081..c104627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -568,7 +568,7 @@ are rewritten so the browser resolves the full module graph natively: Each JS module is injected with an `import.meta.hot` preamble for granular HMR: -```typescript +```javascript if (import.meta.hot) { import.meta.hot.dispose(() => clearInterval(timer)); import.meta.hot.accept(); @@ -758,7 +758,7 @@ mix volt.build --entry index.html Glob patterns are expanded at build time via OXC AST: -```typescript +```javascript const pages = import.meta.glob("./pages/*.ts"); // → { "./pages/home.ts": () => import("./pages/home.ts"), ... } diff --git a/README.md b/README.md index b19e2a7..69f9c7e 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ ExUnit.start(exclude: [:integration]) Volt.Test.ExUnit.install() ``` -```ts +```javascript import { test, expect } from 'volt:test' test('adds numbers', () => { diff --git a/guides/features/css-modules.md b/guides/features/css-modules.md index 22f645f..6826385 100644 --- a/guides/features/css-modules.md +++ b/guides/features/css-modules.md @@ -31,7 +31,7 @@ Both are handled automatically during development and production builds. The sco ### React -```tsx +```javascript import styles from './Card.module.css' export default function Card({ children }) { diff --git a/guides/features/frameworks.md b/guides/features/frameworks.md index 77cf577..5beee9e 100644 --- a/guides/features/frameworks.md +++ b/guides/features/frameworks.md @@ -30,7 +30,7 @@ config :volt, :lint, plugins: [:typescript, :react] ### Entry Point -```tsx +```javascript import { createRoot } from 'react-dom/client' import App from './App' @@ -169,7 +169,7 @@ config :volt, :lint, plugins: [:typescript] ### Entry Point -```tsx +```javascript import { render } from 'solid-js/web' import App from './App' diff --git a/guides/features/glob-imports.md b/guides/features/glob-imports.md index 9a8d0c8..953676b 100644 --- a/guides/features/glob-imports.md +++ b/guides/features/glob-imports.md @@ -104,7 +104,7 @@ const pages = import.meta.glob('./pages/*.ts', { Type-only generic arguments are accepted and ignored at runtime: -```typescript +```javascript const modules = import.meta.glob('./pages/*.ts') ``` diff --git a/guides/features/testing.md b/guides/features/testing.md index 132298e..0f0fc76 100644 --- a/guides/features/testing.md +++ b/guides/features/testing.md @@ -60,7 +60,7 @@ Volt.Test.ExUnit.install(profile: :my_app_web) Import the Vitest-like API from `volt:test`: -```ts +```javascript import { describe, test, expect, beforeEach, afterEach } from 'volt:test' import { add } from './math' @@ -77,7 +77,7 @@ describe('add', () => { Async tests can return promises or use `async` functions: -```ts +```javascript test('loads data', async () => { const value = await Promise.resolve(42) expect(value).toBe(42) @@ -86,7 +86,7 @@ test('loads data', async () => { Relative imports are bundled before execution: -```ts +```javascript import { frameworkName } from './support' test('uses helper module', () => { @@ -98,7 +98,7 @@ test('uses helper module', () => { Skip or mark tests as TODO: -```ts +```javascript test.skip('not ready yet', () => { throw new Error('will not run') }) @@ -108,7 +108,7 @@ test.todo('add coverage for browser behavior') Suites support the same modifiers: -```ts +```javascript describe.skip('external service', () => { test('calls service', () => { // skipped @@ -122,7 +122,7 @@ describe.todo('future behavior', () => { You can also skip dynamically from the test context: -```ts +```javascript test('platform-specific behavior', ({ skip }) => { skip(!navigator.userAgent.includes('Firefox'), 'Firefox only') expect(true).toBe(true) @@ -133,7 +133,7 @@ test('platform-specific behavior', ({ skip }) => { Use `test.each` for table-driven tests: -```ts +```javascript test.each([ [1, 2, 3], [2, 3, 5] @@ -144,7 +144,7 @@ test.each([ Use `describe.each` to repeat a suite for multiple values: -```ts +```javascript describe.each(['en', 'fr'])('locale %s', (locale) => { test('has a locale code', () => { expect(String(locale)).toHaveLength(2) @@ -158,7 +158,7 @@ Supported placeholders in each-test names are `%s`, `%d`, `%i`, `%f`, `%j`, and Volt includes a small core matcher set: -```ts +```javascript expect(value).toBe(expected) expect(value).toEqual(expected) expect(value).toContain(expected) @@ -181,7 +181,7 @@ expect(fn).toThrow('message') All matchers support `.not`: -```ts +```javascript expect('volt').not.toContain('vite') expect(1 + 1).not.toBe(3) ``` @@ -200,7 +200,7 @@ Volt.Test.ExUnit.install( Browser tests still use the same `volt:test` API and still register one ExUnit test per JS `test(...)`. They are best suited for browser-owned runtime behavior such as DOM helpers, client preload logic, and other code that needs real browser globals. Keep Elixir-owned behavior such as manifests, Plug responses, cache state, and build output in ordinary ExUnit tests. -```ts +```javascript import { test, expect } from 'volt:test' test('updates the DOM', () => { @@ -231,7 +231,7 @@ Volt adds useful tags: For example: -```ts +```javascript test('slow calculation', { tags: ['slow'] }, () => { expect(1 + 1).toBe(2) }) From 43f2d868f43f2ae7d2d1a06280ea3824acb75ee2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 6 Jul 2026 23:18:03 +0200 Subject: [PATCH 002/150] Use Builder for Volt test bundling --- guides/features/testing.md | 13 ++ lib/volt/builder.ex | 256 ++++++++++++++++++++---------- lib/volt/builder/bundle.ex | 22 +++ lib/volt/builder/output.ex | 148 +++++++++++++++++ lib/volt/test/browser_runner.ex | 15 +- lib/volt/test/bundle.ex | 19 --- lib/volt/test/bundler.ex | 172 -------------------- lib/volt/test/config.ex | 6 +- lib/volt/test/imports.ex | 55 ------- lib/volt/test/plugin.ex | 61 +++++++ lib/volt/test/runner.ex | 15 +- mix.exs | 5 +- test/volt/builder/bundle_test.exs | 115 ++++++++++++++ test/volt/test/bundler_test.exs | 82 ---------- test/volt/test/config_test.exs | 5 +- test/volt/test/imports_test.exs | 46 ------ 16 files changed, 570 insertions(+), 465 deletions(-) create mode 100644 lib/volt/builder/bundle.ex delete mode 100644 lib/volt/test/bundle.ex delete mode 100644 lib/volt/test/bundler.ex delete mode 100644 lib/volt/test/imports.ex create mode 100644 lib/volt/test/plugin.ex create mode 100644 test/volt/builder/bundle_test.exs delete mode 100644 test/volt/test/bundler_test.exs delete mode 100644 test/volt/test/imports_test.exs diff --git a/guides/features/testing.md b/guides/features/testing.md index 0f0fc76..665cb9c 100644 --- a/guides/features/testing.md +++ b/guides/features/testing.md @@ -34,6 +34,19 @@ config :volt, :test, timeout: 30_000 ``` +Test files are bundled with `Volt.Builder.bundle/1`, so the `bundle:` key accepts normal Volt build graph options such as `:plugins`, `:aliases`, `:node_modules`, `:resolve_dirs`, `:loaders`, and `:define`: + +```elixir +config :volt, :test, + root: "assets", + bundle: [ + plugins: [Volt.Plugin.React], + aliases: %{"@" => "assets/js"} + ] +``` + +Use `bundle:` for source-graph concerns and the top-level test options for discovery/execution concerns. + You can also pass options directly from `test/test_helper.exs`, which is useful for package dogfood tests or custom fixture roots: ```elixir diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index 1c5d7b6..b096507 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -11,7 +11,7 @@ defmodule Volt.Builder do manifest. """ - alias Volt.Builder.{Collector, Output} + alias Volt.Builder.{Bundle, Collector, Output} alias Volt.Paths @css_exts Volt.JS.Extensions.css() @@ -64,88 +64,11 @@ defmodule Volt.Builder do entries = opts |> Keyword.fetch!(:entry) |> List.wrap() |> Enum.map(&resolve_entry(&1, plugins)) - outdir = Keyword.get(opts, :outdir, Paths.static()) |> Path.expand() public_dir = opts |> Keyword.get(:public_dir, false) |> Volt.PublicDir.resolve() - target = opts |> Keyword.get(:target, "") |> to_string() - minify = Keyword.get(opts, :minify, true) - sourcemap_opt = Keyword.get(opts, :sourcemap, true) - sourcemap = sourcemap_opt != false - define = Keyword.get(opts, :define, %{}) - mode = Keyword.get(opts, :mode, "production") - env_prefix = Keyword.get(opts, :env_prefix, "VOLT_") - asset_url_prefix = Keyword.get(opts, :asset_url_prefix, Paths.prefix()) - aliases = Keyword.get(opts, :aliases, %{}) - code_splitting = Keyword.get(opts, :code_splitting, true) - tree_shaking = Keyword.get(opts, :tree_shaking, true) - chunks = Keyword.get(opts, :chunks, %{}) - format = Keyword.get(opts, :format, :iife) - external_raw = Keyword.get(opts, :external, []) - {external_set, external_globals} = normalize_external(external_raw) - - first_entry = hd(entries) - - node_modules = - Keyword.get(opts, :node_modules) || - NPM.Resolution.PackageResolver.find_node_modules(entry_base(first_entry)) - - resolve_dirs = Keyword.get(opts, :resolve_dirs, []) |> Enum.map(&Path.expand/1) - loaders = Keyword.get(opts, :loaders, %{}) - module_types = Keyword.get(opts, :module_types, %{}) - import_source = opts |> Keyword.get(:import_source) |> to_string_or_nil() - hash = Keyword.get(opts, :hash, true) - asset_root = Keyword.get(opts, :root, Paths.assets()) name = Keyword.get(opts, :name) + {ctx, build_ctx} = build_contexts(entries, opts) - env_define = Volt.Env.define(mode: mode, root: File.cwd!(), env_prefix: env_prefix) - plugin_define = Volt.PluginRunner.define(plugins, mode) - - all_define = - env_define - |> Map.merge(plugin_define) - |> Map.merge(define) - - ctx = %Volt.Builder.Context{ - node_modules: node_modules, - resolve_dirs: resolve_dirs, - aliases: aliases, - plugins: plugins, - external: external_set, - external_globals: external_globals, - loaders: loaders, - module_types: module_types, - import_source: import_source, - target: target, - define: all_define, - asset_url_prefix: asset_url_prefix, - asset_outdir: outdir, - asset_root: asset_root - } - - bundle_opts = - [ - minify: minify, - sourcemap: sourcemap, - target: target, - define: all_define, - format: format, - treeshake: tree_shaking, - root: asset_root, - node_modules: node_modules, - resolve_dirs: resolve_dirs - ] ++ if(module_types != %{}, do: [module_types: module_types], else: []) - - build_ctx = %Volt.Builder.BuildContext{ - outdir: outdir, - target: target, - hash: hash, - bundle_opts: bundle_opts, - asset_url_prefix: asset_url_prefix, - code_splitting: code_splitting, - sourcemap_hidden: sourcemap_opt == :hidden, - chunks: chunks - } - - Volt.PublicDir.copy(public_dir, Path.dirname(outdir)) + Volt.PublicDir.copy(public_dir, Path.dirname(build_ctx.outdir)) results = Enum.flat_map(entries, fn entry -> @@ -156,11 +79,95 @@ defmodule Volt.Builder do end) with {:ok, result} <- finalize_build_results(results) do - Volt.Builder.Writer.write_manifest(outdir, result.manifest) + Volt.Builder.Writer.write_manifest(build_ctx.outdir, result.manifest) {:ok, result} end end + @doc """ + Bundle one JavaScript entry with Volt's normal build graph and return it in memory. + + `bundle/1` accepts the same graph, compiler, and bundler options as `build/1`, + including `:plugins`, `:aliases`, `:node_modules`, `:resolve_dirs`, `:loaders`, + `:define`, `:target`, and `:external`. It does not write a manifest or output + JavaScript files, and it always returns a single entry bundle. + + This API is intended for tools that need executable JavaScript from Volt's + production resolver/compiler pipeline without production file output, such as + test runners. + """ + @spec bundle(keyword()) :: {:ok, Bundle.t()} | {:error, term()} + def bundle(opts) do + plugins = Keyword.get(opts, :plugins, []) + entry = opts |> Keyword.fetch!(:entry) |> resolve_entry(plugins) + name = Keyword.get(opts, :name) + + case expand_entry(entry, name) do + [{entry_path, :script, entry_name}] -> + {ctx, build_ctx} = build_contexts([entry_path], Keyword.put_new(opts, :outdir, nil)) + bundle_entry(entry_path, entry_name, ctx, %{build_ctx | code_splitting: false}) + + [{_entry_path, type, _entry_name}] -> + {:error, {:unsupported_bundle_entry, type}} + + entries -> + {:error, {:expected_single_bundle_entry, entries}} + end + end + + defp bundle_entry(entry, _name, ctx, build_ctx) do + with {:ok, modules, _dep_map, workers, specifier_labels, path_labels} <- + Collector.collect(entry, ctx), + :ok <- ensure_in_memory_workers_supported(workers), + {:ok, compiled} <- compile_all(modules, build_ctx.target, ctx) do + compiled = rewrite_nonlocal_labels(compiled, specifier_labels, path_labels) + + output_ctx = %Volt.Builder.OutputContext{ + plugins: ctx.plugins, + external_set: ctx.external, + external_globals: ctx.external_globals, + workers: workers, + worker_results: %{} + } + + out = %Volt.Builder.BuildContext{ + outdir: build_ctx.outdir, + hash: false, + bundle_opts: build_ctx.bundle_opts, + sourcemap_hidden: false, + chunks: %{}, + ctx: output_ctx, + asset_url_prefix: build_ctx.asset_url_prefix + } + + files = + Enum.flat_map(modules, fn {path, _label, _source} -> List.wrap(source_file(path)) end) + + Output.bundle_single(entry, compiled, Enum.uniq(files), out) + end + end + + defp ensure_in_memory_workers_supported(workers) when map_size(workers) == 0, do: :ok + + defp ensure_in_memory_workers_supported(workers), + do: {:error, {:unsupported_bundle_workers, workers}} + + defp source_file(module_id) do + {path, _query} = Volt.URL.split_query(module_id) + + cond do + File.regular?(path) -> + path + + match?({:ok, _id}, Volt.Plugin.EmbeddedModule.parse_id(path)) -> + {:ok, id} = Volt.Plugin.EmbeddedModule.parse_id(path) + if File.regular?(id.parent), do: id.parent + + true -> + nil + end + end + defp build_entry(entry, :script, name, ctx, build_ctx) do %{ outdir: outdir, @@ -245,6 +252,93 @@ defmodule Volt.Builder do end end + defp build_contexts(entries, opts) do + outdir = + case Keyword.get(opts, :outdir, Paths.static()) do + nil -> nil + outdir -> Path.expand(outdir) + end + + target = opts |> Keyword.get(:target, "") |> to_string() + minify = Keyword.get(opts, :minify, true) + sourcemap_opt = Keyword.get(opts, :sourcemap, true) + sourcemap = sourcemap_opt != false + define = Keyword.get(opts, :define, %{}) + mode = Keyword.get(opts, :mode, "production") + env_prefix = Keyword.get(opts, :env_prefix, "VOLT_") + asset_url_prefix = Keyword.get(opts, :asset_url_prefix, Paths.prefix()) + aliases = Keyword.get(opts, :aliases, %{}) + code_splitting = Keyword.get(opts, :code_splitting, true) + tree_shaking = Keyword.get(opts, :tree_shaking, true) + chunks = Keyword.get(opts, :chunks, %{}) + format = Keyword.get(opts, :format, :iife) + external_raw = Keyword.get(opts, :external, []) + {external_set, external_globals} = normalize_external(external_raw) + first_entry = hd(entries) + + node_modules = + Keyword.get(opts, :node_modules) || + NPM.Resolution.PackageResolver.find_node_modules(entry_base(first_entry)) + + resolve_dirs = Keyword.get(opts, :resolve_dirs, []) |> Enum.map(&Path.expand/1) + loaders = Keyword.get(opts, :loaders, %{}) + module_types = Keyword.get(opts, :module_types, %{}) + import_source = opts |> Keyword.get(:import_source) |> to_string_or_nil() + hash = Keyword.get(opts, :hash, true) + asset_root = Keyword.get(opts, :root, Paths.assets()) + + env_define = Volt.Env.define(mode: mode, root: File.cwd!(), env_prefix: env_prefix) + plugin_define = Volt.PluginRunner.define(Keyword.get(opts, :plugins, []), mode) + + all_define = + env_define + |> Map.merge(plugin_define) + |> Map.merge(define) + + ctx = %Volt.Builder.Context{ + node_modules: node_modules, + resolve_dirs: resolve_dirs, + aliases: aliases, + plugins: Keyword.get(opts, :plugins, []), + external: external_set, + external_globals: external_globals, + loaders: loaders, + module_types: module_types, + import_source: import_source, + target: target, + define: all_define, + asset_url_prefix: asset_url_prefix, + asset_outdir: outdir, + asset_root: asset_root + } + + bundle_opts = + [ + minify: minify, + sourcemap: sourcemap, + target: target, + define: all_define, + format: format, + treeshake: tree_shaking, + root: asset_root, + node_modules: node_modules, + resolve_dirs: resolve_dirs + ] ++ if(module_types != %{}, do: [module_types: module_types], else: []) + + build_ctx = %Volt.Builder.BuildContext{ + outdir: outdir, + target: target, + hash: hash, + bundle_opts: bundle_opts, + asset_url_prefix: asset_url_prefix, + code_splitting: code_splitting, + sourcemap_hidden: sourcemap_opt == :hidden, + chunks: chunks + } + + {ctx, build_ctx} + end + defp has_dynamic_imports?(dep_map) do Enum.any?(dep_map, fn {_, %{dynamic: dyn}} -> dyn != [] end) end diff --git a/lib/volt/builder/bundle.ex b/lib/volt/builder/bundle.ex new file mode 100644 index 0000000..6063f97 --- /dev/null +++ b/lib/volt/builder/bundle.ex @@ -0,0 +1,22 @@ +defmodule Volt.Builder.Bundle do + @moduledoc """ + In-memory JavaScript bundle returned from `Volt.Builder.bundle/1`. + + Unlike `Volt.Builder.Result`, which describes files written to disk by + `Volt.Builder.build/1`, this struct carries executable bundle contents and + metadata for callers that need Volt's normal build graph without production + output files. + """ + + @enforce_keys [:entry, :code, :files] + defstruct [:entry, :code, :sourcemap, :files, css: nil, assets: []] + + @type t :: %__MODULE__{ + entry: Path.t(), + code: String.t(), + sourcemap: String.t() | nil, + files: [Path.t()], + css: String.t() | nil, + assets: [term()] + } +end diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index 9262e11..0ff4bce 100644 --- a/lib/volt/builder/output.ex +++ b/lib/volt/builder/output.ex @@ -3,6 +3,55 @@ defmodule Volt.Builder.Output do alias Volt.Builder.{Writer, Rewriter} + @doc "Bundle modules into a single in-memory JS bundle." + def bundle_single(entry, {js_files, css_parts, assets}, files, build_ctx) do + %{ + outdir: outdir, + bundle_opts: bundle_opts, + ctx: ctx, + asset_url_prefix: asset_url_prefix + } = build_ctx + + js_files = Rewriter.rewrite_external_imports(js_files, ctx) + bundle_opts = Keyword.put(bundle_opts, :entry, Path.basename(entry)) + + case bundle_js_files(js_files, bundle_opts) do + {:ok, bundle_result} -> + {js_code, js_sourcemap} = extract_bundle_result(bundle_result) + js_code = Rewriter.inject_external_preamble(js_code, js_files, ctx) + + js_code = + Rewriter.rewrite_worker_urls( + js_code, + Rewriter.all_worker_map(ctx), + Path.rootname(Path.basename(entry)) + ) + + js_code = + Volt.PluginRunner.render_chunk(ctx.plugins, js_code, %{ + name: Path.rootname(Path.basename(entry)), + type: :entry + }) + + css_opts = Keyword.put(bundle_opts, :asset_url_prefix, asset_url_prefix) + + with {:ok, css_result} <- bundle_css(css_parts, outdir, css_opts) do + {:ok, + %Volt.Builder.Bundle{ + entry: entry, + code: js_code, + sourcemap: js_sourcemap, + files: Enum.sort(files), + css: css_code(css_result), + assets: Enum.uniq(assets ++ css_assets(css_result)) + }} + end + + {:error, _} = error -> + error + end + end + @doc "Bundle modules into a single JS file and write output." def build_single(entry, name, {js_files, css_parts, assets}, build_ctx) do %{ @@ -343,6 +392,105 @@ defmodule Volt.Builder.Output do %{entry | assets: Enum.uniq(entry.assets ++ Writer.asset_files(assets))} end + defp bundle_css([], _outdir, _bundle_opts), do: {:ok, nil} + + defp bundle_css(css_parts, nil, bundle_opts) do + with {:ok, %{code: css_code, assets: assets}} <- bundle_css_parts(css_parts, bundle_opts), + {:ok, css_code} <- compile_css(css_code, bundle_opts) do + {:ok, %{code: css_code, assets: assets}} + end + end + + defp bundle_css(css_parts, outdir, bundle_opts) do + with {:ok, %{code: css_code, assets: assets}} <- + rewrite_css_parts(css_parts, outdir, bundle_opts), + {:ok, css_code} <- compile_css(css_code, bundle_opts) do + {:ok, %{code: css_code, assets: assets}} + end + end + + defp bundle_css_parts(css_parts, bundle_opts) do + css_parts + |> Enum.reduce_while({:ok, [], []}, fn css_part, {:ok, code_parts, assets} -> + case bundle_css_part(css_part, bundle_opts) do + {:ok, %{code: code, assets: part_assets}} -> + {:cont, {:ok, [code | code_parts], merge_assets(assets, part_assets)}} + + {:error, _} = error -> + {:halt, error} + end + end) + |> case do + {:ok, code_parts, assets} -> + {:ok, %{code: code_parts |> Enum.reverse() |> Enum.join("\n"), assets: assets}} + + {:error, _} = error -> + error + end + end + + defp bundle_css_part({source_path, css}, bundle_opts) do + root = Keyword.get(bundle_opts, :root) || File.cwd!() + prefix = Keyword.get(bundle_opts, :asset_url_prefix, Volt.Paths.prefix()) + + with {:ok, css} <- Volt.CSS.Imports.inline(css, source_path, bundle_opts), + {:ok, css} <- Volt.CSS.AssetURLRewriter.rewrite_dev(css, source_path, root, prefix) do + {:ok, %{code: css, assets: []}} + end + end + + defp bundle_css_part(css, _bundle_opts), do: {:ok, %{code: css, assets: []}} + + defp rewrite_css_parts(css_parts, outdir, bundle_opts) do + css_parts + |> Enum.reduce_while({:ok, [], []}, fn css_part, {:ok, code_parts, assets} -> + case rewrite_css_part(css_part, outdir, bundle_opts) do + {:ok, %{code: code, assets: part_assets}} -> + {:cont, {:ok, [code | code_parts], merge_assets(assets, part_assets)}} + + {:error, _} = error -> + {:halt, error} + end + end) + |> case do + {:ok, code_parts, assets} -> + {:ok, %{code: code_parts |> Enum.reverse() |> Enum.join("\n"), assets: assets}} + + {:error, _} = error -> + error + end + end + + defp rewrite_css_part({source_path, css}, outdir, bundle_opts) do + with {:ok, css} <- Volt.CSS.Imports.inline(css, source_path, bundle_opts) do + Volt.CSS.AssetURLRewriter.rewrite_with_assets(css, source_path, outdir, + prefix: Keyword.get(bundle_opts, :asset_url_prefix, Volt.Paths.prefix()), + root: Keyword.get(bundle_opts, :root) + ) + end + end + + defp rewrite_css_part(css, _outdir, _bundle_opts), do: {:ok, %{code: css, assets: []}} + + defp compile_css(css_code, bundle_opts) do + case Vize.CSS.compile(css_code, minify: bundle_opts[:minify] || false) do + {:ok, %{errors: [_ | _] = errors}} -> {:error, {:css_compile_failed, errors}} + {:ok, %{code: code}} -> {:ok, code} + end + end + + defp merge_assets(left, right) do + Enum.reduce(right, left, fn asset, acc -> + if asset in acc, do: acc, else: [asset | acc] + end) + end + + defp css_code(nil), do: nil + defp css_code(%{code: code}), do: code + + defp css_assets(nil), do: [] + defp css_assets(%{assets: assets}) when is_list(assets), do: assets + defp css_assets(css_results) do css_results |> Map.values() diff --git a/lib/volt/test/browser_runner.ex b/lib/volt/test/browser_runner.ex index 37839ce..b8704c5 100644 --- a/lib/volt/test/browser_runner.ex +++ b/lib/volt/test/browser_runner.ex @@ -49,8 +49,7 @@ defmodule Volt.Test.BrowserRunner do config = Keyword.fetch!(opts, :config) timeout = Keyword.get(opts, :timeout, config.timeout) - with {:ok, bundled} <- - Volt.Test.Bundler.bundle_file(path, Keyword.get(opts, :compile_opts, [])), + with {:ok, bundled} <- Volt.Builder.bundle(test_bundle_opts(path, config, opts)), {:ok, runtime_code} <- browser_runtime_code(), :ok <- ensure_playwright_started(config, timeout), {:ok, browser} <- launch_browser(browser(config), timeout: timeout), @@ -65,6 +64,18 @@ defmodule Volt.Test.BrowserRunner do end end + defp test_bundle_opts(path, %Config{} = config, opts) do + config.bundle + |> Keyword.merge(Keyword.get(opts, :bundle, [])) + |> Keyword.put(:entry, path) + |> Keyword.put_new(:format, :iife) + |> Keyword.put_new(:minify, false) + |> Keyword.put_new(:sourcemap, true) + |> Keyword.put_new(:code_splitting, false) + |> Keyword.put_new(:mode, "test") + |> Keyword.update(:plugins, [Volt.Test.Plugin], &[Volt.Test.Plugin | List.wrap(&1)]) + end + defp browser_runtime_code do Volt.JS.Runtime.Bundler.bundle_file(Volt.Priv.path({:volt, "ts"}, "test/browser.ts")) end diff --git a/lib/volt/test/bundle.ex b/lib/volt/test/bundle.ex deleted file mode 100644 index 623ae46..0000000 --- a/lib/volt/test/bundle.ex +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Volt.Test.Bundle do - @moduledoc """ - Executable JavaScript bundle for a Volt test file. - - This struct is owned by the Elixir side of the test runner. JavaScript runner - results remain plain JS-decoded maps; bundle metadata such as sourcemaps and - source files stays here instead of being mixed into JS result maps. - """ - - @enforce_keys [:entry, :code, :files] - defstruct [:entry, :code, :sourcemap, :files] - - @type t :: %__MODULE__{ - entry: Path.t(), - code: String.t(), - sourcemap: String.t() | nil, - files: [Path.t()] - } -end diff --git a/lib/volt/test/bundler.ex b/lib/volt/test/bundler.ex deleted file mode 100644 index 80ba425..0000000 --- a/lib/volt/test/bundler.ex +++ /dev/null @@ -1,172 +0,0 @@ -defmodule Volt.Test.Bundler do - @moduledoc """ - Bundles a Volt JS/TS test file and its relative module graph. - - This is intentionally narrower than the production builder: it supports local - relative JavaScript/TypeScript/JSON imports so tests can exercise nearby - modules, while Volt test virtual imports are stripped before bundling. - """ - - alias Volt.JS.Extensions - alias Volt.Test.Bundle - - @spec bundle_file(Path.t(), keyword()) :: {:ok, Bundle.t()} | {:error, term()} - def bundle_file(entry_path, opts \\ []) do - entry_path = Path.expand(entry_path) - - with {:ok, modules} <- collect(entry_path), - root = common_root(Map.keys(modules)), - files = labeled_files(modules, root), - entry = label(entry_path, root), - {:ok, output} <- OXC.bundle(files, bundle_opts(entry, opts)) do - {:ok, bundle(output, entry_path, Map.keys(modules))} - end - end - - defp collect(entry_path) do - do_collect(entry_path, %{}) - end - - defp do_collect(path, modules) do - if Map.has_key?(modules, path) do - {:ok, modules} - else - with {:ok, source} <- read_and_prepare(path), - {:ok, imports} <- imports(source, path) do - modules = Map.put(modules, path, source) - collect_imports(imports, path, modules) - else - {:error, reason} when is_atom(reason) -> {:error, {:file_read_error, path, reason}} - {:error, _} = error -> error - end - end - end - - defp read_and_prepare(path) do - with {:ok, source} <- File.read(path) do - Volt.Test.Imports.strip(source, Path.basename(path)) - end - end - - defp imports(source, path) do - with {:ok, result} <- Volt.JS.ImportExtractor.extract_typed(source, Path.basename(path)) do - imports = - result.imports - |> Enum.map(&elem(&1, 1)) - |> Enum.filter(&test_resolvable_specifier?/1) - - {:ok, imports} - end - end - - defp collect_imports([], _importer, modules), do: {:ok, modules} - - defp collect_imports([specifier | rest], importer, modules) do - with {:ok, resolved} <- resolve(specifier, importer), - {:ok, modules} <- do_collect(resolved, modules) do - collect_imports(rest, importer, modules) - end - end - - defp resolve("volt:client/" <> relative, _importer) do - {:volt, "ts"} - |> Volt.Priv.path(Path.join("client", relative)) - |> resolve_candidates("volt:client/#{relative}", nil) - end - - defp resolve(specifier, importer) do - specifier - |> Path.expand(Path.dirname(importer)) - |> resolve_candidates(specifier, importer) - end - - defp resolve_candidates(base, specifier, importer) do - case Enum.find(candidate_paths(base), &File.regular?/1) do - nil -> {:error, {:module_not_found, specifier, importer}} - path -> {:ok, path} - end - end - - defp candidate_paths(base) do - exact_and_exts = Enum.map(Extensions.node_resolvable_with_exact(), &(base <> &1)) - index_files = Enum.map(Extensions.node_resolvable(), &Path.join(base, "index" <> &1)) - exact_and_exts ++ index_files - end - - defp test_resolvable_specifier?("./" <> _), do: true - defp test_resolvable_specifier?("../" <> _), do: true - defp test_resolvable_specifier?("volt:client/" <> _), do: true - defp test_resolvable_specifier?(_), do: false - - defp labeled_files(modules, root) do - Enum.map(modules, fn {path, source} -> - {label(path, root), rewrite_virtual_imports(source, path, root)} - end) - end - - defp rewrite_virtual_imports(source, path, root) do - case OXC.rewrite_specifiers(source, Path.basename(path), fn - "volt:client/" <> _ = specifier -> - {:ok, resolved} = resolve(specifier, path) - replacement = relative_label_import(label(path, root), label(resolved, root)) - {:rewrite, replacement} - - _specifier -> - :keep - end) do - {:ok, rewritten} -> rewritten - {:error, _errors} -> source - end - end - - defp relative_label_import(importer_label, target_label) do - Volt.Path.relative_import("/" <> importer_label, "/" <> target_label) - end - - defp label(path, root) do - path - |> Path.relative_to(root) - |> Path.split() - |> Enum.join("/") - end - - defp common_root(paths) do - paths - |> Enum.map(&Path.split(Path.dirname(&1))) - |> shared_segments() - |> Path.join() - end - - defp shared_segments([first | rest]) do - rest_tuples = Enum.map(rest, &List.to_tuple/1) - - first - |> Enum.with_index() - |> Enum.take_while(fn {segment, index} -> - Enum.all?(rest_tuples, fn t -> index < tuple_size(t) and elem(t, index) == segment end) - end) - |> Enum.map(&elem(&1, 0)) - end - - defp bundle(output, entry_path, files) do - {code, sourcemap} = output_code_and_sourcemap(output) - - %Bundle{ - entry: entry_path, - code: code, - sourcemap: sourcemap, - files: Enum.sort(files) - } - end - - defp output_code_and_sourcemap(%{code: code, sourcemap: sourcemap}), do: {code, sourcemap} - defp output_code_and_sourcemap(code) when is_binary(code), do: {code, nil} - - defp bundle_opts(entry, opts) do - opts - |> Keyword.take([:define, :target, :sourcemap, :minify]) - |> Keyword.put(:entry, entry) - |> Keyword.put_new(:format, :iife) - |> Keyword.put_new(:sourcemap, true) - end -end diff --git a/lib/volt/test/config.ex b/lib/volt/test/config.ex index 9f9d4ee..6c8fe23 100644 --- a/lib/volt/test/config.ex +++ b/lib/volt/test/config.ex @@ -7,7 +7,8 @@ defmodule Volt.Test.Config do config :volt, :test, root: "assets", include: ["**/*.{test,spec}.{js,ts,jsx,tsx}"], - exclude: ["vendor/**", "node_modules/**"] + exclude: ["vendor/**", "node_modules/**"], + bundle: [plugins: [Volt.Plugin.React]] config :volt, :my_app_web, test: [root: "apps/my_app_web/assets"] @@ -23,6 +24,7 @@ defmodule Volt.Test.Config do browser: false, browsers: [:chromium], timeout: 30_000, + bundle: [], js_runtime: [], playwright: [] @@ -34,6 +36,7 @@ defmodule Volt.Test.Config do browser: boolean(), browsers: [atom()], timeout: timeout(), + bundle: keyword(), js_runtime: keyword(), playwright: keyword() } @@ -46,6 +49,7 @@ defmodule Volt.Test.Config do :browser, :browsers, :timeout, + :bundle, :js_runtime, :playwright ] diff --git a/lib/volt/test/imports.ex b/lib/volt/test/imports.ex deleted file mode 100644 index 64f229e..0000000 --- a/lib/volt/test/imports.ex +++ /dev/null @@ -1,55 +0,0 @@ -defmodule Volt.Test.Imports do - @moduledoc """ - Handles Volt test virtual imports. - - The current runner exposes the `volt:test` API as globals inside the - QuickBEAM runtime. This module removes imports from `volt:test` and - `volt:test/browser` before normal Volt compilation so users can still write - idiomatic test files: - - import { test, expect } from "volt:test" - - test("works", () => { - expect(1 + 1).toBe(2) - }) - - The rewrite is AST-backed and only removes actual import declarations whose - source literal is a Volt test virtual module. - """ - - @virtual_modules MapSet.new(["volt:test", "volt:test/browser"]) - - @spec strip(String.t(), String.t()) :: {:ok, String.t()} | {:error, term()} - def strip(source, filename) do - case OXC.parse(source, filename) do - {:ok, ast} -> {:ok, Volt.JS.Patch.apply(source, patches(ast))} - {:error, _} = error -> error - end - end - - @spec strip!(String.t(), String.t()) :: String.t() - def strip!(source, filename) do - case strip(source, filename) do - {:ok, source} -> source - {:error, errors} -> raise "Volt test import rewrite error: #{inspect(errors)}" - end - end - - defp patches(ast) do - {_ast, patches} = - OXC.postwalk(ast, [], fn - %{type: :import_declaration, source: %{value: specifier}} = node, patches - when is_binary(specifier) -> - if MapSet.member?(@virtual_modules, specifier) do - {node, [Volt.JS.Patch.new(node.start, node.end, "") | patches]} - else - {node, patches} - end - - node, patches -> - {node, patches} - end) - - patches - end -end diff --git a/lib/volt/test/plugin.ex b/lib/volt/test/plugin.ex new file mode 100644 index 0000000..1ba32ab --- /dev/null +++ b/lib/volt/test/plugin.ex @@ -0,0 +1,61 @@ +defmodule Volt.Test.Plugin do + @moduledoc """ + Internal Volt plugin that exposes test-runner virtual modules. + + Test files can import the public `volt:test` API while the Elixir runner + provides the actual functions as globals inside QuickBEAM or the browser. + The plugin also exposes Volt client runtime modules for dogfood tests without + path drilling into `priv/ts/client`. + """ + + @behaviour Volt.Plugin + + @test_modules MapSet.new(["volt:test", "volt:test/browser"]) + + @impl true + def name, do: "volt-test" + + @impl true + def resolve("volt:client/" <> relative, _importer) do + {:volt, "ts"} + |> Volt.Priv.path(Path.join("client", relative)) + |> resolve_client_runtime("volt:client/#{relative}") + end + + def resolve(specifier, _importer) do + if MapSet.member?(@test_modules, specifier), do: {:ok, specifier} + end + + @impl true + def load(specifier) do + if MapSet.member?(@test_modules, specifier) do + {:ok, test_api_module()} + end + end + + defp resolve_client_runtime(base, specifier) do + case Enum.find(candidate_paths(base), &File.regular?/1) do + nil -> {:error, {:module_not_found, specifier, nil}} + path -> {:ok, path} + end + end + + defp candidate_paths(base) do + exact_and_exts = Enum.map(Volt.JS.Extensions.node_resolvable_with_exact(), &(base <> &1)) + index_files = Enum.map(Volt.JS.Extensions.node_resolvable(), &Path.join(base, "index" <> &1)) + exact_and_exts ++ index_files + end + + defp test_api_module do + """ + const api = globalThis; + + export const describe = api.describe; + export const test = api.test; + export const it = api.it; + export const beforeEach = api.beforeEach; + export const afterEach = api.afterEach; + export const expect = api.expect; + """ + end +end diff --git a/lib/volt/test/runner.ex b/lib/volt/test/runner.ex index 9ad1e35..e258182 100644 --- a/lib/volt/test/runner.ex +++ b/lib/volt/test/runner.ex @@ -52,10 +52,9 @@ defmodule Volt.Test.Runner do defp call_test_runtime(path, function, extra_args, opts) do config = Keyword.get_lazy(opts, :config, fn -> Config.read(Keyword.get(opts, :profile)) end) - compile_opts = Keyword.get(opts, :compile_opts, []) timeout = Keyword.get(opts, :timeout, config.timeout) - with {:ok, bundled} <- Volt.Test.Bundler.bundle_file(path, compile_opts), + with {:ok, bundled} <- Volt.Builder.bundle(test_bundle_opts(path, config, opts)), {:ok, runtime} <- start_runtime(config, opts) do try do Runtime.call(runtime, function, [bundled.code, path | extra_args], timeout: timeout) @@ -65,6 +64,18 @@ defmodule Volt.Test.Runner do end end + defp test_bundle_opts(path, %Config{} = config, opts) do + config.bundle + |> Keyword.merge(Keyword.get(opts, :bundle, [])) + |> Keyword.put(:entry, path) + |> Keyword.put_new(:format, :iife) + |> Keyword.put_new(:minify, false) + |> Keyword.put_new(:sourcemap, true) + |> Keyword.put_new(:code_splitting, false) + |> Keyword.put_new(:mode, "test") + |> Keyword.update(:plugins, [Volt.Test.Plugin], &[Volt.Test.Plugin | List.wrap(&1)]) + end + defp start_runtime(%Config{} = config, opts) do runtime_opts = config.js_runtime diff --git a/mix.exs b/mix.exs index 8d3a05e..cb09e85 100644 --- a/mix.exs +++ b/mix.exs @@ -118,7 +118,7 @@ defmodule Volt.MixProject do groups_for_modules: [ Core: [Volt, Volt.Preload, Volt.Config, Volt.Plugin], "Dev Server": [Volt.DevServer, Volt.Watcher, Volt.HMR, Volt.Dev.ConsoleForwarder], - "Production Build": [Volt.Builder, Volt.ChunkGraph, Volt.PublicDir], + "Production Build": [Volt.Builder, Volt.Builder.Bundle, Volt.ChunkGraph, Volt.PublicDir], "Tailwind CSS": [Volt.Tailwind], CSS: [Volt.CSS.Modules], Plugins: [ @@ -141,12 +141,9 @@ defmodule Volt.MixProject do Testing: [ Volt.Test.Assertions, Volt.Test.BrowserRunner, - Volt.Test.Bundle, - Volt.Test.Bundler, Volt.Test.Config, Volt.Test.Discovery, Volt.Test.ExUnit, - Volt.Test.Imports, Volt.Test.Lines, Volt.Test.Result, Volt.Test.Result.Metadata, diff --git a/test/volt/builder/bundle_test.exs b/test/volt/builder/bundle_test.exs new file mode 100644 index 0000000..7743a31 --- /dev/null +++ b/test/volt/builder/bundle_test.exs @@ -0,0 +1,115 @@ +defmodule Volt.Builder.BundleTest do + use ExUnit.Case, async: false + + import Volt.Test.Sigils + + setup do + tmp_dir = + Path.join(System.tmp_dir!(), "volt-builder-bundle-#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp_dir) + + on_exit(fn -> File.rm_rf!(tmp_dir) end) + + %{tmp_dir: tmp_dir} + end + + test "bundles relative TypeScript imports through the Builder graph", %{tmp_dir: tmp_dir} do + write!(tmp_dir, "math.ts", ~TS""" + export function add(left: number, right: number) { + return left + right + } + """) + + entry = + write!(tmp_dir, "math.test.ts", ~TS""" + import { test, expect } from 'volt:test' + import { add } from './math' + + test('adds', () => { + expect(add(1, 2)).toBe(3) + }) + """) + + assert {:ok, + %Volt.Builder.Bundle{ + entry: ^entry, + code: code, + sourcemap: sourcemap, + files: files + }} = + Volt.Builder.bundle( + entry: entry, + plugins: [Volt.Test.Plugin], + minify: false, + sourcemap: true + ) + + assert is_binary(code) + assert is_binary(sourcemap) + assert entry in files + assert Path.join(tmp_dir, "math.ts") in files + refute code =~ "import { add }" + assert code =~ "add" + end + + test "bundles bare imports through the Builder graph", %{tmp_dir: tmp_dir} do + write!(tmp_dir, "node_modules/tiny-pkg/package.json", ~S({"type":"module","main":"index.js"})) + write!(tmp_dir, "node_modules/tiny-pkg/index.js", "export const label = 'tiny'\n") + + entry = + write!(tmp_dir, "bare.test.ts", ~TS""" + import { test, expect } from 'volt:test' + import { label } from 'tiny-pkg' + + test('uses package import', () => { + expect(label).toBe('tiny') + }) + """) + + assert {:ok, %Volt.Builder.Bundle{code: code, files: files}} = + Volt.Builder.bundle(entry: entry, plugins: [Volt.Test.Plugin], minify: false) + + assert code =~ "tiny" + assert Path.join(tmp_dir, "node_modules/tiny-pkg/index.js") in files + end + + test "bundles Volt client virtual imports through the Builder graph", %{tmp_dir: tmp_dir} do + entry = + write!(tmp_dir, "client.test.ts", ~TS""" + import { test, expect } from 'volt:test' + import { renderErrorOverlay } from 'volt:client/overlay' + import { preloadDep } from 'volt:client/preload' + + test('uses client internals', () => { + expect(typeof renderErrorOverlay).toBe('function') + expect(typeof preloadDep).toBe('function') + }) + """) + + assert {:ok, %Volt.Builder.Bundle{code: code, files: files}} = + Volt.Builder.bundle(entry: entry, plugins: [Volt.Test.Plugin], minify: false) + + assert code =~ "renderErrorOverlay" + refute code =~ "volt:client/overlay" + refute code =~ "volt:client/preload" + assert Enum.any?(files, &String.ends_with?(&1, "priv/ts/client/overlay.ts")) + assert Enum.any?(files, &String.ends_with?(&1, "priv/ts/client/preload.ts")) + end + + test "returns module resolution errors", %{tmp_dir: tmp_dir} do + entry = write!(tmp_dir, "missing.test.ts", "import './missing'\n") + + assert {:error, {:not_found, missing}} = + Volt.Builder.bundle(entry: entry, plugins: [Volt.Test.Plugin]) + + assert missing == Path.join(tmp_dir, "missing") + end + + defp write!(root, path, contents) do + path = Path.join(root, path) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, contents) + path + end +end diff --git a/test/volt/test/bundler_test.exs b/test/volt/test/bundler_test.exs deleted file mode 100644 index 5ff43e0..0000000 --- a/test/volt/test/bundler_test.exs +++ /dev/null @@ -1,82 +0,0 @@ -defmodule Volt.Test.BundlerTest do - use ExUnit.Case, async: false - - import Volt.Test.Sigils - - setup do - tmp_dir = - Path.join(System.tmp_dir!(), "volt-test-bundler-#{System.unique_integer([:positive])}") - - File.mkdir_p!(tmp_dir) - - on_exit(fn -> File.rm_rf!(tmp_dir) end) - - %{tmp_dir: tmp_dir} - end - - test "bundles relative TypeScript imports and strips volt:test imports", %{tmp_dir: tmp_dir} do - write!(tmp_dir, "math.ts", ~TS""" - export function add(left: number, right: number) { - return left + right - } - """) - - entry = - write!(tmp_dir, "math.test.ts", ~TS""" - import { test, expect } from 'volt:test' - import { add } from './math' - - test('adds', () => { - expect(add(1, 2)).toBe(3) - }) - """) - - assert {:ok, %Volt.Test.Bundle{entry: ^entry, code: code, sourcemap: sourcemap, files: files}} = - Volt.Test.Bundler.bundle_file(entry) - - assert is_binary(code) - assert is_binary(sourcemap) - assert entry in files - assert Path.join(tmp_dir, "math.ts") in files - refute code =~ "volt:test" - refute code =~ "import { add }" - assert code =~ "add" - end - - test "bundles Volt client virtual imports", %{tmp_dir: tmp_dir} do - entry = - write!(tmp_dir, "client.test.ts", ~TS""" - import { test, expect } from 'volt:test' - import { renderErrorOverlay } from 'volt:client/overlay' - import { preloadDep } from 'volt:client/preload' - - test('uses client internals', () => { - expect(typeof renderErrorOverlay).toBe('function') - expect(typeof preloadDep).toBe('function') - }) - """) - - assert {:ok, %Volt.Test.Bundle{code: code, files: files}} = - Volt.Test.Bundler.bundle_file(entry) - - assert code =~ "renderErrorOverlay" - refute code =~ "volt:client/overlay" - refute code =~ "volt:client/preload" - assert Enum.any?(files, &String.ends_with?(&1, "priv/ts/client/overlay.ts")) - assert Enum.any?(files, &String.ends_with?(&1, "priv/ts/client/preload.ts")) - end - - test "returns module resolution errors", %{tmp_dir: tmp_dir} do - entry = write!(tmp_dir, "missing.test.ts", "import './missing'\n") - - assert {:error, {:module_not_found, "./missing", ^entry}} = - Volt.Test.Bundler.bundle_file(entry) - end - - defp write!(root, path, contents) do - path = Path.join(root, path) - File.mkdir_p!(Path.dirname(path)) - File.write!(path, contents) - path - end -end diff --git a/test/volt/test/config_test.exs b/test/volt/test/config_test.exs index 5c2881b..9266d4f 100644 --- a/test/volt/test/config_test.exs +++ b/test/volt/test/config_test.exs @@ -28,6 +28,7 @@ defmodule Volt.Test.ConfigTest do assert config.browser == false assert config.browsers == [:chromium] assert config.timeout == 30_000 + assert config.bundle == [] end test "reads global test config" do @@ -36,7 +37,8 @@ defmodule Volt.Test.ConfigTest do include: ["**/*.spec.ts"], browser: true, browsers: [:chromium, :firefox], - timeout: 10_000 + timeout: 10_000, + bundle: [plugins: [Volt.Plugin.React]] ) config = Volt.Test.Config.read() @@ -46,6 +48,7 @@ defmodule Volt.Test.ConfigTest do assert config.browser == true assert config.browsers == [:chromium, :firefox] assert config.timeout == 10_000 + assert config.bundle == [plugins: [Volt.Plugin.React]] end test "profile test config overrides global test config" do diff --git a/test/volt/test/imports_test.exs b/test/volt/test/imports_test.exs deleted file mode 100644 index 2e458d8..0000000 --- a/test/volt/test/imports_test.exs +++ /dev/null @@ -1,46 +0,0 @@ -defmodule Volt.Test.ImportsTest do - use ExUnit.Case, async: true - - import Volt.Test.Sigils - - test "strips volt:test import declarations" do - source = ~TS""" - import { describe, test, expect } from "volt:test" - - describe("math", () => { - test("adds", () => expect(1 + 1).toBe(2)) - }) - """ - - assert {:ok, rewritten} = Volt.Test.Imports.strip(source, "math.test.ts") - - refute rewritten =~ "volt:test" - assert rewritten =~ "describe(\"math\"" - end - - test "strips side-effect browser test imports" do - source = ~TS""" - import "volt:test/browser" - test("browser", () => {}) - """ - - assert {:ok, rewritten} = Volt.Test.Imports.strip(source, "browser.test.ts") - - refute rewritten =~ "volt:test/browser" - assert rewritten =~ "test(\"browser\"" - end - - test "keeps non-test imports" do - source = ~TS""" - import { ref } from "vue" - test("uses vue", () => expect(ref).toBe(ref)) - """ - - assert {:ok, ^source} = Volt.Test.Imports.strip(source, "component.test.ts") - end - - test "returns parse errors" do - assert {:error, errors} = Volt.Test.Imports.strip("const = ;", "broken.test.ts") - assert is_list(errors) - end -end From f426c1e10965ffc3044fb847713ffe0d2e163c79 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 6 Jul 2026 23:47:17 +0200 Subject: [PATCH 003/150] Load browser test bundles from script files --- lib/volt/test/browser_runner.ex | 32 +++++++++++++++++--- priv/ts/test/browser.ts | 45 +++++++++++++++++++++++++--- priv/ts/test/core.ts | 9 +++++- priv/ts/test/runner.ts | 25 ++++++++++++++++ priv/types/test/browser-globals.d.ts | 14 ++++++++- 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/lib/volt/test/browser_runner.ex b/lib/volt/test/browser_runner.ex index b8704c5..0e40047 100644 --- a/lib/volt/test/browser_runner.ex +++ b/lib/volt/test/browser_runner.ex @@ -50,14 +50,17 @@ defmodule Volt.Test.BrowserRunner do timeout = Keyword.get(opts, :timeout, config.timeout) with {:ok, bundled} <- Volt.Builder.bundle(test_bundle_opts(path, config, opts)), + {:ok, test_url, cleanup} <- write_browser_test_module(bundled.code), {:ok, runtime_code} <- browser_runtime_code(), :ok <- ensure_playwright_started(config, timeout), {:ok, browser} <- launch_browser(browser(config), timeout: timeout), {:ok, context} <- browser_new_context(browser.guid, timeout: timeout), - {:ok, %{main_frame: frame}} <- browser_context_new_page(context.guid, timeout: timeout) do + {:ok, %{main_frame: frame}} <- browser_context_new_page(context.guid, timeout: timeout), + {:ok, _} <- frame_goto(frame.guid, url: test_url.page, timeout: timeout) do try do - evaluate(frame, runtime_code, bundled.code, path, mode, test_id, timeout) + evaluate(frame, runtime_code, test_url.module, path, mode, test_id, timeout) after + cleanup.() browser_context_close(context.guid, timeout: timeout) browser_close(browser.guid, timeout: timeout) end @@ -101,7 +104,24 @@ defmodule Volt.Test.BrowserRunner do defp browser(%Config{browsers: [browser | _]}), do: browser defp browser(%Config{}), do: :chromium - defp evaluate(frame, runtime_code, test_code, file, mode, test_id, timeout) do + defp write_browser_test_module(code) do + dir = Path.join(System.tmp_dir!(), "volt-browser-test-#{System.unique_integer([:positive])}") + page_path = Path.join(dir, "index.html") + module_path = Path.join(dir, "test.mjs") + + with :ok <- File.mkdir_p(dir), + :ok <- File.write(page_path, ""), + :ok <- File.write(module_path, code) do + {:ok, %{page: file_url(page_path), module: file_url(module_path)}, + fn -> File.rm_rf(dir) end} + end + end + + defp file_url(path) do + %URI{scheme: "file", path: Path.expand(path)} |> URI.to_string() + end + + defp evaluate(frame, runtime_code, test_url, file, mode, test_id, timeout) do with {:ok, _} <- frame_evaluate(frame.guid, expression: runtime_code, @@ -113,7 +133,7 @@ defmodule Volt.Test.BrowserRunner do expression: "payload => globalThis.__voltExecuteBrowserTest(payload)", is_function: true, arg: %{ - "testCode" => test_code, + "testUrl" => test_url, "file" => file, "mode" => Atom.to_string(mode), "testId" => test_id @@ -147,6 +167,10 @@ defmodule Volt.Test.BrowserRunner do apply(PlaywrightEx.BrowserContext, :close, [context_guid, opts]) end + defp frame_goto(frame_guid, opts) do + apply(PlaywrightEx.Frame, :goto, [frame_guid, opts]) + end + defp frame_evaluate(frame_guid, opts) do apply(PlaywrightEx.Frame, :evaluate, [frame_guid, opts]) end diff --git a/priv/ts/test/browser.ts b/priv/ts/test/browser.ts index eb796b3..185a0f6 100644 --- a/priv/ts/test/browser.ts +++ b/priv/ts/test/browser.ts @@ -1,22 +1,59 @@ import './core' type BrowserPayload = { - testCode: string + testCode?: string + testUrl?: string file: string mode: 'collect' | 'run' testId?: number | null } async function executeBrowserTest(payload: BrowserPayload) { + const loader = moduleLoader(payload) + if (payload.mode === 'collect') { - return globalThis.__voltCollectTestModule(payload.testCode, payload.file) + if (loader) return globalThis.__voltCollectLoadedTestModule(loader, payload.file) + return globalThis.__voltCollectTestModule(testCode(payload), payload.file) } if (payload.testId === null || payload.testId === undefined) { - return globalThis.__voltRunTestModule(payload.testCode, payload.file) + if (loader) return globalThis.__voltRunLoadedTestModule(loader, payload.file) + return globalThis.__voltRunTestModule(testCode(payload), payload.file) + } + + if (loader) return globalThis.__voltRunLoadedTestModule(loader, payload.file, payload.testId) + return globalThis.__voltRunTestModule(testCode(payload), payload.file, payload.testId) +} + +function moduleLoader(payload: BrowserPayload): (() => Promise) | undefined { + if (!payload.testUrl) return undefined + return () => loadScript(`${payload.testUrl}?volt_test=${Date.now()}_${Math.random()}`) +} + +function loadScript(src: string) { + return new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = src + script.onload = () => { + script.remove() + resolve() + } + script.onerror = () => { + script.remove() + reject(new Error(`Failed to load Volt browser test module: ${src}`)) + } + document.head.appendChild(script) + }) +} + +function testCode(payload: BrowserPayload) { + const code = payload.testCode ?? globalThis.__voltBrowserTestCode + + if (typeof code !== 'string') { + throw new Error('Volt browser test code was not installed') } - return globalThis.__voltRunTestModule(payload.testCode, payload.file, payload.testId) + return code } Object.assign(globalThis, { __voltExecuteBrowserTest: executeBrowserTest }) diff --git a/priv/ts/test/core.ts b/priv/ts/test/core.ts index 11d4b2e..83ec2a1 100644 --- a/priv/ts/test/core.ts +++ b/priv/ts/test/core.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, it, test } from './api' import { expect } from './matchers' -import { __voltCollectTestModule, __voltRunTestModule } from './runner' +import { + __voltCollectLoadedTestModule, + __voltCollectTestModule, + __voltRunLoadedTestModule, + __voltRunTestModule +} from './runner' Object.assign(globalThis, { describe, @@ -9,6 +14,8 @@ Object.assign(globalThis, { beforeEach, afterEach, expect, + __voltCollectLoadedTestModule, __voltCollectTestModule, + __voltRunLoadedTestModule, __voltRunTestModule }) diff --git a/priv/ts/test/runner.ts b/priv/ts/test/runner.ts index e2043a0..fa23913 100644 --- a/priv/ts/test/runner.ts +++ b/priv/ts/test/runner.ts @@ -8,9 +8,29 @@ export async function __voltCollectTestModule(code: string, file: string) { return state.tests.map(metadata) } +export async function __voltCollectLoadedTestModule(loader: () => Promise, _file: string) { + await loadBrowserTestModule(loader) + + return state.tests.map(metadata) +} + export async function __voltRunTestModule(code: string, file: string, onlyId?: number) { loadTestModule(code, file) + return runLoadedTests(file, onlyId) +} + +export async function __voltRunLoadedTestModule( + loader: () => Promise, + file: string, + onlyId?: number +) { + await loadBrowserTestModule(loader) + + return runLoadedTests(file, onlyId) +} + +async function runLoadedTests(file: string, onlyId?: number) { const results: Volt.Test.Result[] = [] const startedAt = Date.now() const tests = @@ -58,6 +78,11 @@ function loadTestModule(code: string, _file: string) { ;(0, eval)(code) } +async function loadBrowserTestModule(loader: () => Promise) { + reset() + await loader() +} + function runRegisteredTest(registered: Volt.Test.Registered) { if (!registered.fn) { throw new Error(`Missing test function for ${fullName(registered)}`) diff --git a/priv/types/test/browser-globals.d.ts b/priv/types/test/browser-globals.d.ts index 68f9cf7..f591fb6 100644 --- a/priv/types/test/browser-globals.d.ts +++ b/priv/types/test/browser-globals.d.ts @@ -1,10 +1,22 @@ declare global { function __voltCollectTestModule(code: string, file: string): Promise + function __voltCollectLoadedTestModule( + loader: () => Promise, + file: string + ): Promise function __voltRunTestModule(code: string, file: string, onlyId?: number): Promise + function __voltRunLoadedTestModule( + loader: () => Promise, + file: string, + onlyId?: number + ): Promise + + var __voltBrowserTestCode: string | undefined var __voltExecuteBrowserTest: | ((payload: { - testCode: string + testCode?: string + testUrl?: string file: string mode: 'collect' | 'run' testId?: number | null From 0664e460affe60261d559ca34eaac249537c51b0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 6 Jul 2026 23:50:17 +0200 Subject: [PATCH 004/150] Prepare 0.15.1 release --- CHANGELOG.md | 12 ++++++++++++ mix.exs | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c104627..77d7e12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 0.15.1 - 2026-07-06 + +### Added + +- `Volt.Builder.bundle/1` returns in-memory bundles from Volt's normal Builder graph for test runners and other tooling that need executable JavaScript without production output files. +- Volt JavaScript tests now use Builder-backed bundling, so test files can use normal Volt bundle options through `bundle: [...]`, including plugins, aliases, `node_modules`, and `resolve_dirs`. + +### Changed + +- Removed the separate `Volt.Test.Bundler` layer in favor of `Volt.Builder.bundle/1`. +- Browser test execution now loads large test bundles from temporary script files instead of sending bundle source through Playwright protocol arguments. + ## 0.15.0 - 2026-07-06 ### Added diff --git a/mix.exs b/mix.exs index cb09e85..b676b78 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.15.0" + @version "0.15.1" @source_url "https://github.com/elixir-volt/volt" def project do From 203b939356b68e31045da134e9764f6c96273b58 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 7 Jul 2026 00:12:34 +0200 Subject: [PATCH 005/150] Fix package import specifier resolution --- CHANGELOG.md | 6 +++ lib/volt/builder/resolver.ex | 2 +- lib/volt/dev_server.ex | 2 +- lib/volt/js/specifier.ex | 25 ++++++++++++ mix.exs | 2 +- test/volt/builder/resolution_test.exs | 40 +++++++++++++++++++ .../volt/dev_server/import_rewriting_test.exs | 24 +++++++++++ test/volt/js/specifier_test.exs | 21 ++++++++++ 8 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 lib/volt/js/specifier.ex create mode 100644 test/volt/js/specifier_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d7e12..9cf984f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +## 0.15.2 - 2026-07-06 + +### Fixed + +- Builder and dev-server resolution now preserve package import specifiers such as `#client/constants`, fixing packages that use nearest-package `imports` maps, including Svelte's browser runtime internals. + ## 0.15.1 - 2026-07-06 ### Added diff --git a/lib/volt/builder/resolver.ex b/lib/volt/builder/resolver.ex index 73177fc..ca59b81 100644 --- a/lib/volt/builder/resolver.ex +++ b/lib/volt/builder/resolver.ex @@ -7,7 +7,7 @@ defmodule Volt.Builder.Resolver do Returns `{:ok, path}`, `:skip` (for externals/node builtins), or `{:error, reason}`. """ def resolve(specifier, importer, ctx) do - {path_specifier, query} = Volt.URL.split_query(specifier) + {path_specifier, query} = Volt.JS.Specifier.split_query(specifier) if external?(path_specifier, ctx.external) do :skip diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index e380ec7..3258903 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -528,7 +528,7 @@ defmodule Volt.DevServer do # ── Import rewriting ────────────────────────────────────────────── defp rewrite_dev_specifier(specifier, importer, config) do - {path_specifier, query} = URL.split_query(specifier) + {path_specifier, query} = Volt.JS.Specifier.split_query(specifier) case Volt.PluginRunner.resolve(config.plugins, path_specifier, importer) do {:ok, resolved} -> diff --git a/lib/volt/js/specifier.ex b/lib/volt/js/specifier.ex new file mode 100644 index 0000000..032d2b4 --- /dev/null +++ b/lib/volt/js/specifier.ex @@ -0,0 +1,25 @@ +defmodule Volt.JS.Specifier do + @moduledoc "Helpers for JavaScript module specifier strings." + + @doc """ + Splits a JavaScript module specifier into its path-like part and query string. + + Unlike URL parsing, this preserves package import specifiers such as + `#client/constants`, where the leading `#` is part of the JavaScript module + specifier rather than a URL fragment marker. + """ + @spec split_query(String.t()) :: {String.t(), String.t()} + def split_query("#" <> rest) do + {path, query} = split_on_query(rest) + {"#" <> path, query} + end + + def split_query(specifier), do: Volt.URL.split_query(specifier) + + defp split_on_query(specifier) do + case String.split(specifier, "?", parts: 2) do + [path, query] -> {path, query} + [path] -> {path, ""} + end + end +end diff --git a/mix.exs b/mix.exs index b676b78..351e6a1 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.15.1" + @version "0.15.2" @source_url "https://github.com/elixir-volt/volt" def project do diff --git a/test/volt/builder/resolution_test.exs b/test/volt/builder/resolution_test.exs index f47338a..a505fbe 100644 --- a/test/volt/builder/resolution_test.exs +++ b/test/volt/builder/resolution_test.exs @@ -84,6 +84,46 @@ defmodule Volt.Builder.ResolutionTest do assert js =~ "b-" end + test "resolves package imports from nearest package imports map" do + File.mkdir_p!(Path.join(@fixture_dir, "node_modules/pkg/src/internal")) + + File.write!( + Path.join(@fixture_dir, "node_modules/pkg/package.json"), + Jason.encode!(%{ + "name" => "pkg", + "type" => "module", + "exports" => %{"." => "./src/index.js"}, + "imports" => %{"#internal/value" => "./src/internal/value.js"} + }) + ) + + File.write!( + Path.join(@fixture_dir, "node_modules/pkg/src/internal/value.js"), + "export const value = 'package-import';\n" + ) + + File.write!( + Path.join(@fixture_dir, "node_modules/pkg/src/index.js"), + "import { value } from '#internal/value';\nexport { value };\n" + ) + + File.write!( + Path.join(@fixture_dir, "src/package_import_app.ts"), + "import { value } from 'pkg';\nconsole.log(value);\n" + ) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/package_import_app.ts"), + outdir: @outdir, + minify: false, + sourcemap: false, + node_modules: Path.join(@fixture_dir, "node_modules") + ) + + assert File.read!(result.js.path) =~ "package-import" + end + test "loaders option enables JSX in .js files" do File.write!(Path.join(@fixture_dir, "src/jsx_app.js"), """ const App = () =>
Hello
diff --git a/test/volt/dev_server/import_rewriting_test.exs b/test/volt/dev_server/import_rewriting_test.exs index ca5ca4b..6a95b0a 100644 --- a/test/volt/dev_server/import_rewriting_test.exs +++ b/test/volt/dev_server/import_rewriting_test.exs @@ -16,6 +16,30 @@ defmodule Volt.DevServer.ImportRewritingTest do refute conn.resp_body =~ "'./utils'" end + test "rewrites package imports from nearest package imports map" do + File.mkdir_p!(Path.join(@fixture_dir, "src/internal")) + + File.write!( + Path.join(@fixture_dir, "src/package.json"), + Jason.encode!(%{ + "name" => "fixture-app", + "imports" => %{"#internal/value" => "./internal/value.js"} + }) + ) + + File.write!(Path.join(@fixture_dir, "src/internal/value.js"), "export const value = 1") + + File.write!( + Path.join(@fixture_dir, "src/entry.ts"), + "import { value } from '#internal/value'\nconsole.log(value)" + ) + + conn = call_dev_server("/assets/entry.ts") + assert conn.status == 200 + assert conn.resp_body =~ "/assets/internal/value.js" + refute conn.resp_body =~ "'#internal/value'" + end + test "rewrites CSS imports to import-mode URLs" do File.write!(Path.join(@fixture_dir, "src/entry.ts"), "import './style.css'") diff --git a/test/volt/js/specifier_test.exs b/test/volt/js/specifier_test.exs new file mode 100644 index 0000000..47039e9 --- /dev/null +++ b/test/volt/js/specifier_test.exs @@ -0,0 +1,21 @@ +defmodule Volt.JS.SpecifierTest do + use ExUnit.Case, async: true + + doctest Volt.JS.Specifier + + describe "split_query/1" do + test "preserves package import specifiers" do + assert Volt.JS.Specifier.split_query("#client/constants") == {"#client/constants", ""} + end + + test "splits queries on package import specifiers" do + assert Volt.JS.Specifier.split_query("#client/constants?raw") == + {"#client/constants", "raw"} + end + + test "keeps URL-style behavior for non-package-import specifiers" do + assert Volt.JS.Specifier.split_query("./style.css?inline") == {"./style.css", "inline"} + assert Volt.JS.Specifier.split_query("./module.js#hash") == {"./module.js", ""} + end + end +end From c74987b006bcae8d985965699339c2a6ea179b4f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 7 Jul 2026 11:18:28 +0200 Subject: [PATCH 006/150] Clarify browser test docs example --- guides/features/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/features/testing.md b/guides/features/testing.md index 665cb9c..9bd1083 100644 --- a/guides/features/testing.md +++ b/guides/features/testing.md @@ -206,7 +206,7 @@ Use `browser: true` when tests need real browser globals such as `window`, `docu ```elixir Volt.Test.ExUnit.install( root: "test/browser", - include: ["**/*.browser.test.ts"], + include: ["**/*.test.ts"], browser: true ) ``` From d7ad2cd7fa106b4f00efe890b0379a0c4d1a5f18 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 7 Jul 2026 11:52:35 +0200 Subject: [PATCH 007/150] Add inline Volt test assertions --- CHANGELOG.md | 4 + guides/features/testing.md | 62 ++++++++++++++ lib/volt/test/assertions.ex | 68 +++++++++++++++ lib/volt/test/case.ex | 21 +++++ lib/volt/test/inline.ex | 94 +++++++++++++++++++++ test/volt/test/case_test.exs | 111 +++++++++++++++++++++++++ test/volt/test/fixtures/jsx-runtime.js | 5 ++ test/volt/test/sigils_test.exs | 2 + 8 files changed, 367 insertions(+) create mode 100644 lib/volt/test/case.ex create mode 100644 lib/volt/test/inline.ex create mode 100644 test/volt/test/case_test.exs create mode 100644 test/volt/test/fixtures/jsx-runtime.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf984f..779ca1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Added `Volt.Test.Case` for inline ExUnit assertions against JavaScript and TypeScript snippets using `assert ~JS`, `assert ~TS`, `assert ~JSX`, and `assert ~TSX`. + ## 0.15.2 - 2026-07-06 ### Fixed diff --git a/guides/features/testing.md b/guides/features/testing.md index 9bd1083..0e9eb65 100644 --- a/guides/features/testing.md +++ b/guides/features/testing.md @@ -107,6 +107,68 @@ test('uses helper module', () => { }) ``` +## Inline ExUnit assertions + +For small checks that belong next to ordinary Elixir tests, use `Volt.Test.Case` and assert against a JavaScript-like sigil: + +```elixir +defmodule MyApp.FrontendTest do + use Volt.Test.Case, async: false + + test "formats a label" do + assert ~TS""" + const label: string = "hello volt" + + expect(label.toUpperCase()).toBe("HELLO VOLT") + """ + end +end +``` + +`Volt.Test.Case` keeps normal ExUnit assertions working and adds inline support for `~JS`, `~TS`, `~JSX`, and `~TSX`. The snippet is wrapped as one Volt JavaScript test. Top-level imports stay top-level, so snippets can import application code: + +```elixir +defmodule MyApp.CounterTest do + use Volt.Test.Case, async: false + + test "uses app code" do + assert ~TS""" + import { label } from "app/counter" + + expect(label()).toBe("Count") + """ + end +end +``` + +Inline assertions use the same global `config :volt, :test` settings as file-based tests. Put aliases and framework plugins in `config/test.exs` when they are shared: + +```elixir +config :volt, :test, + bundle: [ + aliases: %{"app" => "assets/js"}, + plugins: [Volt.Plugin.React] + ] +``` + +Module-level options are useful when a group of inline assertions needs a different environment: + +```elixir +defmodule MyApp.BrowserSnippetTest do + use Volt.Test.Case, async: false, browser: true + + test "updates the DOM" do + assert ~TS""" + document.body.innerHTML = "" + + expect(document.querySelector("button")?.textContent).toBe("Save") + """ + end +end +``` + +Use file-based tests for larger JS suites, repeated setup, or behavior that is primarily owned by browser code. Use inline assertions for compact checks that read better inside an ExUnit module. + ## Test and suite modifiers Skip or mark tests as TODO: diff --git a/lib/volt/test/assertions.ex b/lib/volt/test/assertions.ex index 55b2988..f632aa9 100644 --- a/lib/volt/test/assertions.ex +++ b/lib/volt/test/assertions.ex @@ -5,6 +5,74 @@ defmodule Volt.Test.Assertions do import ExUnit.Assertions + @doc """ + Runs JavaScript-like sigils as Volt test assertions. + + This extends ExUnit's `assert/1` for `~JS`, `~TS`, `~JSX`, and `~TSX` + snippets while delegating all other expressions to `ExUnit.Assertions.assert/1`. + """ + defmacro assert(source) + + defmacro assert(source) do + case js_sigil_extension(source) do + nil -> + quote do + ExUnit.Assertions.assert(unquote(source)) + end + + extension -> + assert_js_quote(source, [extension: extension], __CALLER__) + end + end + + @doc """ + Delegates to ExUnit's `assert/2`. + """ + defmacro assert(source, message) do + quote do + ExUnit.Assertions.assert(unquote(source), unquote(message)) + end + end + + @doc """ + Runs a JavaScript or TypeScript snippet as a Volt test assertion. + + The snippet body is wrapped in a single Volt `test(...)`. Top-level import + declarations are preserved outside the wrapper so snippets can import app + code while keeping assertions inline in ExUnit tests. + """ + defmacro assert_js(source, opts \\ []) do + assert_js_quote(source, maybe_put_sigil_extension(source, opts), __CALLER__) + end + + defp assert_js_quote(source, opts, caller) do + config = Module.get_attribute(caller.module, :volt_test_config) || [] + + quote do + Volt.Test.Assertions.__assert_js__( + unquote(source), + Keyword.merge(unquote(Macro.escape(config)), unquote(opts)), + %{file: __ENV__.file, line: __ENV__.line, module: __MODULE__} + ) + end + end + + defp maybe_put_sigil_extension(source, opts) do + case {Keyword.has_key?(opts, :extension), js_sigil_extension(source)} do + {false, extension} when is_binary(extension) -> Keyword.put(opts, :extension, extension) + _ -> opts + end + end + + defp js_sigil_extension({:sigil_JS, _meta, _args}), do: ".js" + defp js_sigil_extension({:sigil_TS, _meta, _args}), do: ".ts" + defp js_sigil_extension({:sigil_JSX, _meta, _args}), do: ".jsx" + defp js_sigil_extension({:sigil_TSX, _meta, _args}), do: ".tsx" + defp js_sigil_extension(_source), do: nil + + @doc false + def __assert_js__(source, opts, caller), do: Volt.Test.Inline.assert!(source, opts, caller) + @spec assert_passed!(Volt.Test.Result.t()) :: :ok def assert_passed!(%Volt.Test.Result{status: :passed}), do: :ok diff --git a/lib/volt/test/case.ex b/lib/volt/test/case.ex new file mode 100644 index 0000000..e29f440 --- /dev/null +++ b/lib/volt/test/case.ex @@ -0,0 +1,21 @@ +defmodule Volt.Test.Case do + @moduledoc """ + ExUnit case template for inline Volt JavaScript and TypeScript assertions. + + `use Volt.Test.Case` imports `Volt.Test.Assertions` and `Volt.Test.Sigils` so + tests can run small JavaScript or TypeScript snippets from ordinary ExUnit + test modules. + """ + + use ExUnit.CaseTemplate + + using opts do + quote do + import ExUnit.Assertions, except: [assert: 1, assert: 2] + import Volt.Test.Assertions + import Volt.Test.Sigils + + @volt_test_config unquote(opts) + end + end +end diff --git a/lib/volt/test/inline.ex b/lib/volt/test/inline.ex new file mode 100644 index 0000000..721e1e5 --- /dev/null +++ b/lib/volt/test/inline.ex @@ -0,0 +1,94 @@ +defmodule Volt.Test.Inline do + @moduledoc "Runs inline JavaScript and TypeScript snippets as Volt tests." + + import ExUnit.Assertions, only: [flunk: 1] + + @spec assert!(String.t(), keyword(), map()) :: :ok + def assert!(source, opts, caller) when is_binary(source) and is_list(opts) do + config = Volt.Test.Config.read(opts) + runner = if config.browser, do: Volt.Test.BrowserRunner, else: Volt.Test.Runner + + case write_test(source, opts, caller) do + {:ok, path, cleanup} -> + try do + case runner.run_file(path, config: config) do + {:ok, result} -> + result + |> Map.put(:file, "#{caller.file}:#{caller.line}") + |> Volt.Test.Assertions.assert_passed!() + + {:error, reason} -> + flunk("Volt JS assertion failed to run: #{inspect(reason)}") + end + after + cleanup.() + end + + {:error, reason} -> + flunk("Volt JS assertion failed to run: #{inspect(reason)}") + end + end + + defp write_test(source, opts, caller) do + dir = Path.join(System.tmp_dir!(), "volt-inline-test-#{System.unique_integer([:positive])}") + extension = opts |> Keyword.get(:extension, ".ts") |> normalize_extension() + path = Path.join(dir, "assertion#{extension}") + + with :ok <- File.mkdir_p(dir), + {:ok, code} <- test_source(source, Path.basename(path)), + :ok <- File.write(path, code) do + {:ok, path, fn -> File.rm_rf(dir) end} + else + {:error, reason} -> {:error, {caller.file, caller.line, reason}} + end + end + + defp normalize_extension(extension) when is_binary(extension) do + if String.starts_with?(extension, "."), do: extension, else: "." <> extension + end + + defp normalize_extension(extension) when is_atom(extension), + do: extension |> Atom.to_string() |> normalize_extension() + + defp test_source(source, filename) do + with {:ok, ast} <- OXC.parse(source, filename) do + ranges = import_ranges(ast) + imports = extract_ranges(source, ranges) + body = remove_ranges(source, ranges) |> String.trim() + + {:ok, + [ + imports, + if(imports == "", do: "", else: "\n\n"), + "test('inline assertion', async () => {\n", + body, + "\n})\n" + ] + |> IO.iodata_to_binary()} + end + end + + defp import_ranges(%{body: body}) when is_list(body) do + body + |> Enum.filter(&match?(%{type: :import_declaration}, &1)) + |> Enum.map(&{&1.start, &1.end}) + end + + defp import_ranges(_ast), do: [] + + defp extract_ranges(source, ranges) do + ranges + |> Enum.map(fn {start_pos, end_pos} -> + binary_part(source, start_pos, end_pos - start_pos) + end) + |> Enum.join("\n") + end + + defp remove_ranges(source, ranges) do + ranges + |> Enum.sort_by(&elem(&1, 0), :desc) + |> Enum.reduce(source, fn {start_pos, end_pos}, acc -> + binary_part(acc, 0, start_pos) <> binary_part(acc, end_pos, byte_size(acc) - end_pos) + end) + end +end diff --git a/test/volt/test/case_test.exs b/test/volt/test/case_test.exs new file mode 100644 index 0000000..5daa15c --- /dev/null +++ b/test/volt/test/case_test.exs @@ -0,0 +1,111 @@ +defmodule Volt.Test.CaseTest do + use Volt.Test.Case, async: false + + test "assert runs inline TypeScript sigil assertions" do + assert ~TS""" + type User = { name: string } + + const user: User = { name: 'Ada' } + expect(user.name).toBe('Ada') + """ + end + + test "assert preserves top-level imports" do + dir = Path.join(System.tmp_dir!(), "volt-test-case-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + File.write!(Path.join(dir, "math.ts"), "export const answer = 42\n") + + on_exit(fn -> File.rm_rf!(dir) end) + + assert_js( + ~TS""" + import { answer } from 'case-math' + + expect(answer).toBe(42) + """, + bundle: [aliases: %{"case-math" => Path.join(dir, "math.ts")}] + ) + end + + test "assert reports JavaScript assertion failures" do + error = + assert_raise ExUnit.AssertionError, fn -> + assert ~TS""" + expect(1 + 1).toBe(3) + """ + end + + assert Exception.message(error) =~ "to be" + assert Exception.message(error) =~ __ENV__.file + end + + test "regular ExUnit assertions still work" do + assert 1 + 1 == 2 + assert "volt" =~ "vo" + end + + test "assert handles JSX and TSX sigils" do + assert ~JSX""" + expect('jsx').toContain('x') + """ + + assert ~TSX""" + const value: string = 'tsx' + expect(value).toContain('x') + """ + end + + test "assert uses global Volt test config" do + previous = Application.get_env(:volt, :test, []) + runtime = Path.expand("fixtures/jsx-runtime.js", __DIR__) + + Application.put_env(:volt, :test, bundle: [aliases: %{"react/jsx-runtime" => runtime}]) + + on_exit(fn -> Application.put_env(:volt, :test, previous) end) + + assert ~TSX""" + const node = + expect(node.props.children).toBe('Save') + """ + end + + test "assert_js remains available explicitly" do + assert_js(~JS""" + expect('volt').toContain('vo') + """) + end +end + +defmodule Volt.Test.CaseJSXTest do + use Volt.Test.Case, + async: false, + bundle: [ + aliases: %{ + "react/jsx-runtime" => Path.expand("fixtures/jsx-runtime.js", __DIR__) + } + ] + + test "assert bundles JSX runtime imports" do + assert ~JSX""" + const node =
+ expect(node.props['data-name']).toBe('volt') + """ + + assert ~TSX""" + const node = + expect(node.props['data-count']).toBe(1) + """ + end +end + +defmodule Volt.Test.CaseBrowserTest do + use Volt.Test.Case, async: false, browser: true + + test "assert can run in a browser" do + assert ~TS""" + document.body.innerHTML = '' + + expect(document.querySelector('button')?.textContent).toBe('Save') + """ + end +end diff --git a/test/volt/test/fixtures/jsx-runtime.js b/test/volt/test/fixtures/jsx-runtime.js new file mode 100644 index 0000000..821499a --- /dev/null +++ b/test/volt/test/fixtures/jsx-runtime.js @@ -0,0 +1,5 @@ +export function jsx(type, props) { + return { type, props } +} + +export const jsxs = jsx diff --git a/test/volt/test/sigils_test.exs b/test/volt/test/sigils_test.exs index 61ccedc..895edf8 100644 --- a/test/volt/test/sigils_test.exs +++ b/test/volt/test/sigils_test.exs @@ -13,6 +13,8 @@ defmodule Volt.Test.SigilsTest do test "validates JS-like sigils with the v modifier" do assert ~TS"const value: number = 1"v == "const value: number = 1" + assert ~JSX"const node =
"v == "const node =
" + assert ~TSX"const node: JSX.Element =
"v == "const node: JSX.Element =
" assert_raise ArgumentError, ~r/invalid snippet\.ts source/, fn -> ~TS"const = ;"v From 3c7ac8a05c3d8f095b9161da97497facbc11cc9f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 7 Jul 2026 11:53:29 +0200 Subject: [PATCH 008/150] Prepare 0.15.3 release --- CHANGELOG.md | 2 ++ mix.exs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 779ca1b..07375c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.15.3 - 2026-07-06 + ### Added - Added `Volt.Test.Case` for inline ExUnit assertions against JavaScript and TypeScript snippets using `assert ~JS`, `assert ~TS`, `assert ~JSX`, and `assert ~TSX`. diff --git a/mix.exs b/mix.exs index 351e6a1..e8503bc 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.15.2" + @version "0.15.3" @source_url "https://github.com/elixir-volt/volt" def project do From 75cce31f5b3ffb47fb5c87afa705446034e24adf Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 7 Jul 2026 13:00:37 +0200 Subject: [PATCH 009/150] Move test helpers under test support --- {test_support => test/support}/volt/builder_case.exs | 0 {test_support => test/support}/volt/dev_server_case.exs | 0 test/test_helper.exs | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename {test_support => test/support}/volt/builder_case.exs (100%) rename {test_support => test/support}/volt/dev_server_case.exs (100%) diff --git a/test_support/volt/builder_case.exs b/test/support/volt/builder_case.exs similarity index 100% rename from test_support/volt/builder_case.exs rename to test/support/volt/builder_case.exs diff --git a/test_support/volt/dev_server_case.exs b/test/support/volt/dev_server_case.exs similarity index 100% rename from test_support/volt/dev_server_case.exs rename to test/support/volt/dev_server_case.exs diff --git a/test/test_helper.exs b/test/test_helper.exs index f0cd6bc..94b4500 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,4 +1,4 @@ -Path.wildcard(Path.expand("../test_support/**/*.exs", __DIR__)) +Path.wildcard(Path.expand("support/**/*.exs", __DIR__)) |> Enum.sort() |> Enum.each(&Code.require_file/1) From 3682c53ee40c4f53d9b4a3f10e0105994282d775 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 7 Jul 2026 15:08:10 +0200 Subject: [PATCH 010/150] Resolve Phoenix colocated Tailwind CSS --- CHANGELOG.md | 4 ++++ guides/features/tailwind.md | 6 ++++++ lib/volt/tailwind/resolver.ex | 10 +++++++++- mix.exs | 1 + test/volt/tailwind_test.exs | 23 +++++++++++++++++++++++ 5 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07375c0..9021186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- Tailwind stylesheet and module resolution now searches Phoenix-style asset locations such as `deps` and `Mix.Project.build_path()`, fixing `phoenix-colocated/*/colocated.css` imports in fresh Phoenix projects. + ## 0.15.3 - 2026-07-06 ### Added diff --git a/guides/features/tailwind.md b/guides/features/tailwind.md index 8e56b72..d4cb881 100644 --- a/guides/features/tailwind.md +++ b/guides/features/tailwind.md @@ -32,6 +32,12 @@ Tailwind `@plugin` and `@config` directives are resolved and bundled automatical npm packages are resolved from `node_modules`. Local files are resolved relative to the CSS input file. The full `require()` graph is bundled so the Tailwind runtime evaluates self-contained modules. +Volt also searches Phoenix-style asset locations for Tailwind imports and plugins. This lets Phoenix LiveView colocated CSS imports resolve from the Mix build path: + +```css +@import "phoenix-colocated/my_app/colocated.css"; +``` + ## Incremental Rebuilds In dev mode, only changed files are re-scanned. If a `.heex` template adds new Tailwind classes, only those new candidates trigger a CSS rebuild — the browser gets a style-only update without a page reload. diff --git a/lib/volt/tailwind/resolver.ex b/lib/volt/tailwind/resolver.ex index c68fffe..57f5292 100644 --- a/lib/volt/tailwind/resolver.ex +++ b/lib/volt/tailwind/resolver.ex @@ -52,7 +52,7 @@ defmodule Volt.Tailwind.Resolver do {package_name, subpath} = NPM.Resolution.PackageResolver.split_specifier(id) resolved = - [find_node_modules_for(base), runtime_node_modules] + ([find_node_modules_for(base), runtime_node_modules] ++ phoenix_tailwind_search_dirs()) |> Enum.reject(&is_nil/1) |> Enum.uniq() |> Enum.find_value(fn node_modules -> @@ -142,4 +142,12 @@ defmodule Volt.Tailwind.Resolver do defp find_node_modules_for(base) do base |> normalize_base() |> NPM.Resolution.PackageResolver.find_node_modules() end + + defp phoenix_tailwind_search_dirs do + if Code.ensure_loaded?(Mix.Project) do + [Path.expand("deps"), Mix.Project.build_path()] + else + [] + end + end end diff --git a/mix.exs b/mix.exs index e8503bc..01729a6 100644 --- a/mix.exs +++ b/mix.exs @@ -12,6 +12,7 @@ defmodule Volt.MixProject do start_permanent: Mix.env() == :prod, deps: deps(), aliases: aliases(), + test_ignore_filters: [&String.starts_with?(&1, "test/support/")], dialyzer: [plt_add_apps: [:mix], flags: [:no_opaque]], name: "Volt", description: diff --git a/test/volt/tailwind_test.exs b/test/volt/tailwind_test.exs index 42a668d..56873f0 100644 --- a/test/volt/tailwind_test.exs +++ b/test/volt/tailwind_test.exs @@ -69,6 +69,29 @@ defmodule Volt.TailwindTest do assert css =~ "rebeccapurple" end + test "loads Phoenix colocated CSS from the Mix build path" do + colocated_dir = Path.join(Mix.Project.build_path(), "phoenix-colocated/volt_tailwind_test") + File.mkdir_p!(colocated_dir) + + File.write!( + Path.join(colocated_dir, "colocated.css"), + ".colocated-card { color: oklch(70% 0.2 40); }" + ) + + on_exit(fn -> File.rm_rf!(colocated_dir) end) + + {:ok, css} = + Volt.Tailwind.build( + sources: [%{base: @fixture_dir, pattern: "**/*.html"}], + css: + "@import \"phoenix-colocated/volt_tailwind_test/colocated.css\";\n@import \"tailwindcss\";", + css_base: @fixture_dir + ) + + assert css =~ ".colocated-card" + assert css =~ "oklch(70% 0.2 40)" + end + test "loads local references via @reference" do File.write!(Path.join(@fixture_dir, "reference.css"), """ @import "tailwindcss"; From 76daf274d754d06ddec302bfbcb21c7a818b5deb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 7 Jul 2026 15:21:10 +0200 Subject: [PATCH 011/150] Dogfood LiveView colocated CSS in vanilla example --- examples/vanilla/README.md | 2 ++ examples/vanilla/assets/css/app.css | 3 +++ examples/vanilla/assets/js/pages/metadata.ts | 2 +- examples/vanilla/config/config.exs | 2 +- .../vanilla/lib/vanilla_example_web/colocated_css.ex | 8 ++++++++ .../components/layouts/root.html.heex | 2 +- .../vanilla/lib/vanilla_example_web/live/home_live.ex | 9 ++++++++- examples/vanilla/mix.exs | 2 +- examples/vanilla/mix.lock | 10 +++++----- test/volt/integration/phoenix_example_test.exs | 8 ++++++++ 10 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 examples/vanilla/lib/vanilla_example_web/colocated_css.ex diff --git a/examples/vanilla/README.md b/examples/vanilla/README.md index 8ca0af5..08b3bd7 100644 --- a/examples/vanilla/README.md +++ b/examples/vanilla/README.md @@ -2,6 +2,8 @@ A minimal Phoenix app using Volt with plain TypeScript — no frontend framework. +It also dogfoods Phoenix LiveView colocated CSS via `Phoenix.LiveView.ColocatedCSS` and a Tailwind import from `phoenix-colocated/vanilla_example/colocated.css`. + ```sh mix setup mix phx.server diff --git a/examples/vanilla/assets/css/app.css b/examples/vanilla/assets/css/app.css index fb71c09..cfbac3d 100644 --- a/examples/vanilla/assets/css/app.css +++ b/examples/vanilla/assets/css/app.css @@ -1,3 +1,6 @@ @import "tailwindcss" source(none); +@import "phoenix-colocated/vanilla_example/colocated.css"; + @source "../js"; @source "../../lib"; +@source "../../_build/dev/phoenix-colocated/vanilla_example/*/"; diff --git a/examples/vanilla/assets/js/pages/metadata.ts b/examples/vanilla/assets/js/pages/metadata.ts index 415c940..bbecb4d 100644 --- a/examples/vanilla/assets/js/pages/metadata.ts +++ b/examples/vanilla/assets/js/pages/metadata.ts @@ -9,7 +9,7 @@ export type PageMetadata = { export const pages: PageMetadata[] = [ { slug: 'home', title: home.title, description: home.description }, - { slug: 'about', title: about.title, description: about.description } + { slug: 'about', title: about.title, description: about.description }, ] export function pageTitles() { diff --git a/examples/vanilla/config/config.exs b/examples/vanilla/config/config.exs index 0d64373..24684b4 100644 --- a/examples/vanilla/config/config.exs +++ b/examples/vanilla/config/config.exs @@ -24,7 +24,7 @@ config :volt, target: :es2020, minify: false, hash: false, - resolve_dirs: ["node_modules", "deps"], + resolve_dirs: ["node_modules", "deps", Mix.Project.build_path()], tailwind: [ css: "assets/css/app.css", sources: [ diff --git a/examples/vanilla/lib/vanilla_example_web/colocated_css.ex b/examples/vanilla/lib/vanilla_example_web/colocated_css.ex new file mode 100644 index 0000000..0087122 --- /dev/null +++ b/examples/vanilla/lib/vanilla_example_web/colocated_css.ex @@ -0,0 +1,8 @@ +defmodule VanillaExampleWeb.ColocatedCSS do + use Phoenix.LiveView.ColocatedCSS + + @impl true + def transform("style", _attrs, css, _meta) do + {:ok, css, []} + end +end diff --git a/examples/vanilla/lib/vanilla_example_web/components/layouts/root.html.heex b/examples/vanilla/lib/vanilla_example_web/components/layouts/root.html.heex index e22474c..a61e1a3 100644 --- a/examples/vanilla/lib/vanilla_example_web/components/layouts/root.html.heex +++ b/examples/vanilla/lib/vanilla_example_web/components/layouts/root.html.heex @@ -6,7 +6,7 @@ <.live_title default="Volt Vanilla Example">Volt Vanilla Example - + {@inner_content} diff --git a/examples/vanilla/lib/vanilla_example_web/live/home_live.ex b/examples/vanilla/lib/vanilla_example_web/live/home_live.ex index 61b27a4..6dd362e 100644 --- a/examples/vanilla/lib/vanilla_example_web/live/home_live.ex +++ b/examples/vanilla/lib/vanilla_example_web/live/home_live.ex @@ -11,6 +11,13 @@ defmodule VanillaExampleWeb.HomeLive do def render(assigns) do ~H""" + +
@@ -21,7 +28,7 @@ defmodule VanillaExampleWeb.HomeLive do
-
+

Counter

' - expect(window.location.href).toMatch('about:blank') + const pageURL = new URL(window.location.href) + expect(pageURL.protocol).toBe('file:') + expect(pageURL.pathname.endsWith('/index.html')).toBe(true) expect(document.querySelector('#ok')?.textContent).toBe('OK') }) """) From 3fd3aff9186db4083c9c75ba76fb3b6c6c4b8c9e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 09:20:06 +0300 Subject: [PATCH 083/150] Release Volt 0.18.0 --- CHANGELOG.md | 5 ++--- mix.exs | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bf6f15..1474bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.18.0 - 2026-09-15 + ### Breaking changes - Rename `Volt.JS.Helpers` to `Volt.JS.Discovery`, with `files/0,1` and `format_files/0` replacing `discover_files/0,1` and `discover_format_files/0`. @@ -17,9 +19,7 @@ - Require QuickBEAM 0.11.1 or later in the 0.11 series for release-optimized, baseline-CPU precompiled runtimes and the Mint 1.10 security dependency floor. - Write one production manifest at the configured asset output root instead of separate JavaScript and Tailwind manifests. -- Make `mix volt.build` a thin adapter over `Volt.build/1`. - Emit Tailwind development artifacts atomically and skip style HMR broadcasts when generated CSS is unchanged. -- Normalize in-memory build manifests and emitted asset metadata to typed structs before JSON serialization. ### Fixed @@ -32,7 +32,6 @@ - Require Igniter 0.8.4 or later to prevent terminal escape injection through package metadata in installer confirmation prompts (CVE-2026-82584). - Require Bandit 1.12.5 or later in the Phoenix examples to address HTTP/2 header validation and connection-window starvation (CVE-2026-75484, CVE-2026-74836). - - Update Phoenix example locks to patched Phoenix and LiveView releases for channel-join and long-poll denial of service, presence-client crashes, link scheme validation, and redirect validation. ## 0.17.11 - 2026-09-04 diff --git a/mix.exs b/mix.exs index 06ffeed..f02973e 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.11" + @version "0.18.0" @source_url "https://github.com/elixir-volt/volt" def project do From 0681fc65cc98a3e5f57d303da473477c91be7c18 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 15:27:20 +0300 Subject: [PATCH 084/150] Wire shared browser types into starter TypeScript configs (#39) --- CHANGELOG.md | 4 + lib/mix/tasks/volt/install.ex | 69 +++++++++++++++++ priv/templates/volt.install/tsconfig.json.eex | 12 +++ priv/types/client/styles.d.ts | 6 ++ test/mix/tasks/volt/install_test.exs | 77 +++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 priv/templates/volt.install/tsconfig.json.eex create mode 100644 priv/types/client/styles.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1474bd9..4306522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Wire shared browser declarations into TypeScript configurations during installation. Create a configuration when absent, update ordinary JSON configurations without discarding existing settings, and provide manual guidance for JSONC and inherited or referenced projects. + ## 0.18.0 - 2026-09-15 ### Breaking changes diff --git a/lib/mix/tasks/volt/install.ex b/lib/mix/tasks/volt/install.ex index 9abc231..f643403 100644 --- a/lib/mix/tasks/volt/install.ex +++ b/lib/mix/tasks/volt/install.ex @@ -21,6 +21,17 @@ if Code.ensure_loaded?(Igniter) do 6. Add `Volt.Formatter` plugin to `.formatter.exs` 7. Add `Volt.DevServer` plug to your endpoint 8. Configure the automatic Volt watcher in `config/dev.exs` + 9. Wire Volt client types into TypeScript configuration + + The installer checks `tsconfig.json`, `assets/tsconfig.json`, and + `assets/js/tsconfig.json`. Ordinary JSON configurations retain their settings + and receive explicit declaration files, even when dependencies are excluded. + When none exists, the installer creates a root configuration from a template. + + JSONC and configurations using `extends` or project references are left + unchanged with guidance to include `deps/volt/priv/types/client/**/*.d.ts` + (relative to the browser configuration). No local `ImportMeta` declaration + is needed. """ use Igniter.Mix.Task @@ -58,10 +69,68 @@ if Code.ensure_loaded?(Igniter) do |> add_format_config() |> add_lint_config() |> add_formatter_plugin() + |> add_typescript_config() |> add_dev_config(app_name, endpoint) |> add_dev_server_plug() end + defp add_typescript_config(igniter) do + configs = [ + {"tsconfig.json", "deps"}, + {"assets/tsconfig.json", "../deps"}, + {"assets/js/tsconfig.json", "../../deps"} + ] + + existing = Enum.filter(configs, fn {path, _} -> Igniter.exists?(igniter, path) end) + + case existing do + [] -> + template = Application.app_dir(:volt, "priv/templates/volt.install/tsconfig.json.eex") + Igniter.copy_template(igniter, template, "tsconfig.json", []) + + paths -> + Enum.reduce(paths, igniter, fn {path, deps}, igniter -> + Igniter.update_file(igniter, path, &include_client_types(&1, deps)) + end) + end + end + + defp include_client_types(source, deps) do + with {:ok, config} when is_map(config) <- Jason.decode(Rewrite.Source.get(source, :content)), + false <- Map.has_key?(config, "extends") or Map.has_key?(config, "references"), + files when is_list(files) <- Map.get(config, "files", []), + true <- Enum.all?(files, &is_binary/1) do + types = [ + "#{deps}/volt/priv/types/client/hmr.d.ts", + "#{deps}/volt/priv/types/client/preload.d.ts", + "#{deps}/volt/priv/types/client/styles.d.ts" + ] + + updated = + config + |> preserve_default_include() + |> Map.put("files", Enum.uniq(files ++ types)) + + if updated == config do + source + else + Rewrite.Source.update(source, :content, Jason.encode!(updated, pretty: true) <> "\n") + end + else + _ -> + {:warning, + "Volt left #{source.path} unchanged: JSONC, inherited and referenced projects require " <> + "manual configuration. Include #{deps}/volt/priv/types/client/**/*.d.ts in the browser " <> + "project, ensuring it is not excluded. No application-local ImportMeta declaration is needed."} + end + end + + defp preserve_default_include(config) do + if Map.has_key?(config, "files") or Map.has_key?(config, "include"), + do: config, + else: Map.put(config, "include", ["**/*"]) + end + # ── Remove old tooling ── defp remove_old_deps(igniter) do diff --git a/priv/templates/volt.install/tsconfig.json.eex b/priv/templates/volt.install/tsconfig.json.eex new file mode 100644 index 0000000..0aa0c0f --- /dev/null +++ b/priv/templates/volt.install/tsconfig.json.eex @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "allowJs": true, + "noEmit": true, + "strict": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["assets/**/*", "deps/volt/priv/types/client/**/*.d.ts"] +} diff --git a/priv/types/client/styles.d.ts b/priv/types/client/styles.d.ts new file mode 100644 index 0000000..38f6005 --- /dev/null +++ b/priv/types/client/styles.d.ts @@ -0,0 +1,6 @@ +declare module "*.module.css" { + const classes: Readonly>; + export default classes; +} + +declare module "*.css" {} diff --git a/test/mix/tasks/volt/install_test.exs b/test/mix/tasks/volt/install_test.exs index edf3c53..744d377 100644 --- a/test/mix/tasks/volt/install_test.exs +++ b/test/mix/tasks/volt/install_test.exs @@ -132,9 +132,86 @@ defmodule Mix.Tasks.Volt.InstallTest do igniter.rewrite.sources["mix.exs"] |> Rewrite.Source.get(:content) + tsconfig = + igniter.rewrite.sources["tsconfig.json"] + |> Rewrite.Source.get(:content) + |> Jason.decode!() + + assert tsconfig["compilerOptions"]["strict"] + assert "deps/volt/priv/types/client/**/*.d.ts" in tsconfig["include"] + assert mix_content =~ ~s("assets.setup": []) assert mix_content =~ ~s("assets.build": ["compile", "volt.build --tailwind"]) assert mix_content =~ ~s("assets.deploy": ["volt.build --tailwind", "phx.digest"]) end + + test "preserves options, exclusions and explicit files while adding client types once" do + original = %{ + "compilerOptions" => %{"strict" => false}, + "exclude" => ["deps"], + "files" => ["assets/app.ts"] + } + + igniter = install_with_config("tsconfig.json", Jason.encode!(original)) + config = config(igniter, "tsconfig.json") + assert config["compilerOptions"] == original["compilerOptions"] + assert config["exclude"] == ["deps"] + refute Map.has_key?(config, "include") + + assert config["files"] == [ + "assets/app.ts", + "deps/volt/priv/types/client/hmr.d.ts", + "deps/volt/priv/types/client/preload.d.ts", + "deps/volt/priv/types/client/styles.d.ts" + ] + + assert config(Mix.Tasks.Volt.Install.igniter(igniter), "tsconfig.json") == config + end + + test "retains default source discovery when adding explicit declaration files" do + config = + install_with_config("tsconfig.json", ~s({"compilerOptions":{"strict":true}})) + |> config("tsconfig.json") + + assert config["include"] == ["**/*"] + end + + test "keeps an authored include list" do + config = + install_with_config("tsconfig.json", ~s({"include":["src/**/*.ts"]})) + |> config("tsconfig.json") + + assert config["include"] == ["src/**/*.ts"] + end + + test "updates an assets configuration with paths relative to that configuration" do + igniter = install_with_config("assets/tsconfig.json", ~s({"include":["**/*.ts"]})) + refute Map.has_key?(igniter.rewrite.sources, "tsconfig.json") + + assert "../deps/volt/priv/types/client/hmr.d.ts" in config(igniter, "assets/tsconfig.json")[ + "files" + ] + end + + test "leaves JSONC and inherited or referenced configurations untouched with guidance" do + for content <- [ + "{ // preserve this comment\n}", + ~s({"extends":"./base.json"}), + ~s({"references":[{"path":"./assets"}]}) + ] do + igniter = install_with_config("tsconfig.json", content) + assert Rewrite.Source.get(igniter.rewrite.sources["tsconfig.json"], :content) == content + assert Enum.any?(igniter.warnings, &String.contains?(&1, "manual configuration")) + end + end + end + + defp install_with_config(path, content) do + Test.test_project(app_name: :demo, files: %{path => content}) + |> Mix.Tasks.Volt.Install.igniter() + end + + defp config(igniter, path) do + igniter.rewrite.sources[path] |> Rewrite.Source.get(:content) |> Jason.decode!() end end From 8771ca81bbfbc7d66cbbbf9f5d53eb7bbddb33ff Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 15:31:45 +0300 Subject: [PATCH 085/150] Release Volt 0.18.1 --- CHANGELOG.md | 3 +++ mix.exs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4306522..053df36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,12 @@ ## Unreleased +## 0.18.1 - 2026-09-15 + ### Added - Wire shared browser declarations into TypeScript configurations during installation. Create a configuration when absent, update ordinary JSON configurations without discarding existing settings, and provide manual guidance for JSONC and inherited or referenced projects. +- Declare CSS side-effect imports and CSS modules in the shared browser declarations. ## 0.18.0 - 2026-09-15 diff --git a/mix.exs b/mix.exs index f02973e..2218b05 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.18.0" + @version "0.18.1" @source_url "https://github.com/elixir-volt/volt" def project do From 7539dea15a94834f9939752cc56fa3165029416e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 05:05:57 +0400 Subject: [PATCH 086/150] 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 087/150] 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 088/150] 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 089/150] 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 090/150] 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 091/150] 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 092/150] 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 093/150] 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 094/150] 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 095/150] 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 096/150] Show a diagnostic's stacktrace in the error overlay --- lib/volt/dev/error.ex | 22 +- priv/ts/client/overlay.ts | 8 + .../node_modules/@types/trusted-types/LICENSE | 21 - .../@types/trusted-types/lib/index.d.ts | 64 -- .../@types/trusted-types/package.json | 45 -- priv/ts/node_modules/lit-html/LICENSE | 28 - .../lit-html/development/directive.d.ts | 66 -- .../development/directives/unsafe-html.d.ts | 27 - .../lit-html/development/lit-html.d.ts | 621 ----------------- priv/ts/node_modules/lit-html/directive.js | 7 - .../lit-html/directives/unsafe-html.js | 7 - priv/ts/node_modules/lit-html/lit-html.js | 7 - priv/ts/node_modules/lit-html/package.json | 629 ------------------ test/volt/dev/error_test.exs | 9 + 14 files changed, 36 insertions(+), 1525 deletions(-) delete mode 100644 priv/ts/node_modules/@types/trusted-types/LICENSE delete mode 100644 priv/ts/node_modules/@types/trusted-types/lib/index.d.ts delete mode 100644 priv/ts/node_modules/@types/trusted-types/package.json delete mode 100644 priv/ts/node_modules/lit-html/LICENSE delete mode 100644 priv/ts/node_modules/lit-html/development/directive.d.ts delete mode 100644 priv/ts/node_modules/lit-html/development/directives/unsafe-html.d.ts delete mode 100644 priv/ts/node_modules/lit-html/development/lit-html.d.ts delete mode 100644 priv/ts/node_modules/lit-html/directive.js delete mode 100644 priv/ts/node_modules/lit-html/directives/unsafe-html.js delete mode 100644 priv/ts/node_modules/lit-html/lit-html.js delete mode 100644 priv/ts/node_modules/lit-html/package.json diff --git a/lib/volt/dev/error.ex b/lib/volt/dev/error.ex index d1438e7..a207426 100644 --- a/lib/volt/dev/error.ex +++ b/lib/volt/dev/error.ex @@ -20,7 +20,8 @@ defmodule Volt.Dev.Error do column: pos_integer() | nil, hint: String.t() | nil, frame: String.t() | nil, - frame_html: String.t() | nil + frame_html: String.t() | nil, + stack: String.t() | nil } @doc """ @@ -43,7 +44,21 @@ defmodule Volt.Dev.Error do {line, column} = position(diagnostic[:position]) hint = if is_binary(diagnostic[:details]), do: diagnostic[:details] - [entry(message, opts, file: diagnostic[:file], line: line, column: column, hint: hint)] + stack = + case diagnostic[:stacktrace] do + [_ | _] = stacktrace -> Exception.format_stacktrace(stacktrace) + _none -> nil + end + + [ + entry(message, opts, + file: diagnostic[:file], + line: line, + column: column, + hint: hint, + stack: stack + ) + ] end def entries(message, opts) when is_binary(message), do: [entry(message, opts, [])] @@ -62,7 +77,8 @@ defmodule Volt.Dev.Error do column: column, hint: fields[:hint], frame: frame, - frame_html: frame_html + frame_html: frame_html, + stack: fields[:stack] } end diff --git a/priv/ts/client/overlay.ts b/priv/ts/client/overlay.ts index 3d3edf6..8fe7f91 100644 --- a/priv/ts/client/overlay.ts +++ b/priv/ts/client/overlay.ts @@ -9,6 +9,7 @@ export type VoltError = { hint?: string | null frame?: string | null frame_html?: string | null + stack?: string | null } type VoltErrorOverlayOptions = { @@ -85,6 +86,12 @@ const styles = html`') + + expect(morphDocument(next, '')).toBe(false) +}) + +test('leaves elements owned by client code alone', () => { + document.body.innerHTML = + '

first

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

second

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

one

"), page("

two

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

#{text}

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

one

"), page("

two

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

x

) + + now = + ~s(

x

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