diff --git a/.formatter.exs b/.formatter.exs index d2cda26..5e3c114 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,17 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + plugins: [Volt.Formatter], + inputs: [ + "{mix,.formatter}.exs", + "{config,lib,test}/**/*.{ex,exs}", + "priv/ts/{client,compilers,test}/**/*.ts" + ], + volt: [ + trailing_comma: :none, + tab_width: 2, + semi: false, + single_quote: true, + print_width: 100, + arrow_parens: :always + ] ] diff --git a/.reach.exs b/.reach.exs index 3556620..9d67e6e 100644 --- a/.reach.exs +++ b/.reach.exs @@ -99,6 +99,7 @@ logic = [ "Volt.HTMLEntry", "Volt.JS.AST", "Volt.JS.Check", + "Volt.JS.CommonJS", "Volt.JS.Lint.Config", "Volt.JS.Extensions", "Volt.JS.Format", diff --git a/CHANGELOG.md b/CHANGELOG.md index d1ec343..a44d1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## Unreleased + +### Breaking changes + +- Formatter options moved from `config :volt, :format` to the `:volt` key of `.formatter.exs`, where `mix format` plugins keep their options. `config :volt, :format` now only holds the build output format (`:iife`, `:esm`, or `:cjs`), so both can be set. A keyword list under `config :volt, :format` raises an `ArgumentError` with the options to move: + + # .formatter.exs + [ + plugins: [Volt.Formatter], + volt: [semi: false, single_quote: true] + ] + + The formatting-only `:root`, `:sources`, and `:ignore` overrides for `mix volt.js.format` and `mix volt.js.check` move to the same key. `.oxfmtrc.json` and `.prettierrc.json` are still read when there is no `:volt` key. `mix igniter.install volt` writes the options to `.formatter.exs`. +- 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. + +### Added + +- 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. + +### 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. +- Stop the dev server reloading pages in a loop when a watched file is rewritten with identical content, as Phoenix LiveView does for colocated hooks on every code reload. The watcher now compares file contents before rebuilding. +- Load a single instance of each pre-bundled dependency in development. Pre-bundles imported their siblings and shared chunks without the `?v=` hash that application modules use, so browsers loaded packages such as Vue twice and component libraries built on them failed to render. +- Report why a development session is unavailable. A second watcher with different options, such as a `Mix.Tasks.Volt.Dev` entry in the endpoint's `:watchers` next to `plug Volt.DevServer`, answered every request with a bare 503; the response and the log now name the conflict and how to resolve it. +- Log `Pre-bundled N vendor package(s)` only when packages are bundled. Phoenix initializes plugs on every request in development, so it was logged per request. +- Read external globals once in code-split IIFE output. Each chunk also declared them at the top level of the script, where two chunks importing the same name would clash. +- Stop watchers before the Tailwind processes they release their contexts to. Volt's supervisor stopped the Tailwind registry first, so every watcher with Tailwind enabled crashed with `unknown registry: Volt.Tailwind.Registry` on shutdown. +- Skip `tsconfig.json` path mappings that only point at declaration files. A types-only mapping such as `"topbar": ["./types/topbar.d.ts"]` became a bundler alias and bundled the `.d.ts` file in place of the package. + ## 0.19.4 - 2026-10-03 ### Compatibility diff --git a/README.md b/README.md index b7ab98e..c1b5733 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,11 @@ JS/TS formatting, linting, and testing run inside the BEAM. `mix format` handles ```elixir # .formatter.exs -[plugins: [Volt.Formatter], inputs: ["assets/**/*.{js,ts,jsx,tsx}"]] +[ + plugins: [Volt.Formatter], + inputs: ["assets/**/*.{js,ts,jsx,tsx}"], + volt: [semi: false, single_quote: true] +] ``` ```bash diff --git a/config/config.exs b/config/config.exs index 98e14d3..f97b032 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,13 +1,5 @@ import Config -config :volt, :format, - trailing_comma: :none, - tab_width: 2, - semi: false, - single_quote: true, - print_width: 100, - arrow_parens: :always - config :volt, :lint, plugins: ["typescript", "import", "unicorn"], rules: %{ diff --git a/examples/react/.formatter.exs b/examples/react/.formatter.exs index a0877aa..daa575d 100644 --- a/examples/react/.formatter.exs +++ b/examples/react/.formatter.exs @@ -5,5 +5,6 @@ "*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] diff --git a/examples/react/config/config.exs b/examples/react/config/config.exs index 8c8f13d..e8fd363 100644 --- a/examples/react/config/config.exs +++ b/examples/react/config/config.exs @@ -33,10 +33,6 @@ config :volt, ], import_source: "react" -config :volt, :format, - semi: false, - single_quote: true - config :volt, :lint, plugins: ["typescript", "react"], tsgolint: System.find_executable("tsgolint"), diff --git a/examples/solid/.formatter.exs b/examples/solid/.formatter.exs index a0877aa..daa575d 100644 --- a/examples/solid/.formatter.exs +++ b/examples/solid/.formatter.exs @@ -5,5 +5,6 @@ "*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] diff --git a/examples/solid/config/config.exs b/examples/solid/config/config.exs index 595d9f0..af08475 100644 --- a/examples/solid/config/config.exs +++ b/examples/solid/config/config.exs @@ -33,10 +33,6 @@ config :volt, ] ] -config :volt, :format, - semi: false, - single_quote: true - config :volt, :lint, plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), diff --git a/examples/svelte/.formatter.exs b/examples/svelte/.formatter.exs index a0877aa..daa575d 100644 --- a/examples/svelte/.formatter.exs +++ b/examples/svelte/.formatter.exs @@ -5,5 +5,6 @@ "*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] diff --git a/examples/svelte/config/config.exs b/examples/svelte/config/config.exs index e367836..5cea6b3 100644 --- a/examples/svelte/config/config.exs +++ b/examples/svelte/config/config.exs @@ -32,10 +32,6 @@ config :volt, ] ] -config :volt, :format, - semi: false, - single_quote: true - config :volt, :lint, plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), diff --git a/examples/vanilla/.formatter.exs b/examples/vanilla/.formatter.exs index a0877aa..daa575d 100644 --- a/examples/vanilla/.formatter.exs +++ b/examples/vanilla/.formatter.exs @@ -5,5 +5,6 @@ "*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] diff --git a/examples/vanilla/config/config.exs b/examples/vanilla/config/config.exs index aba95e3..82f1b8d 100644 --- a/examples/vanilla/config/config.exs +++ b/examples/vanilla/config/config.exs @@ -32,10 +32,6 @@ config :volt, ] ] -config :volt, :format, - semi: false, - single_quote: true - config :volt, :lint, plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), diff --git a/examples/vue/.formatter.exs b/examples/vue/.formatter.exs index a0877aa..daa575d 100644 --- a/examples/vue/.formatter.exs +++ b/examples/vue/.formatter.exs @@ -5,5 +5,6 @@ "*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] diff --git a/examples/vue/config/config.exs b/examples/vue/config/config.exs index e27ff43..85409ab 100644 --- a/examples/vue/config/config.exs +++ b/examples/vue/config/config.exs @@ -32,10 +32,6 @@ config :volt, ] ] -config :volt, :format, - semi: false, - single_quote: true - config :volt, :lint, plugins: ["typescript", "vue"], tsgolint: System.find_executable("tsgolint"), diff --git a/guides/cheatsheets/configuration.cheatmd b/guides/cheatsheets/configuration.cheatmd index e25567b..ae2a457 100644 --- a/guides/cheatsheets/configuration.cheatmd +++ b/guides/cheatsheets/configuration.cheatmd @@ -121,7 +121,7 @@ Prefix or list of prefixes exposed through `import.meta.env`. config :volt, external: ~w(phoenix phoenix_html) ``` -Exclude packages from the bundle. +Exclude packages from the bundle. IIFE output reads them from globals; `:esm` and `:cjs` keep the imports. ### Aliases @@ -259,34 +259,36 @@ The dev-server Plug starts a supervised watcher on the first request. Set this t ## Formatting Options {: .col-2} +Set under the `:volt` key of `.formatter.exs`. + ### Semi ```elixir -config :volt, :format, semi: false +[volt: [semi: false]] ``` ### Single Quote ```elixir -config :volt, :format, single_quote: true +[volt: [single_quote: true]] ``` ### Print Width ```elixir -config :volt, :format, print_width: 100 +[volt: [print_width: 100]] ``` ### Trailing Comma ```elixir -config :volt, :format, trailing_comma: :none +[volt: [trailing_comma: :none]] ``` ### Arrow Parens ```elixir -config :volt, :format, arrow_parens: :always +[volt: [arrow_parens: :always]] ``` ## Lint Options diff --git a/guides/deployment/production-builds.md b/guides/deployment/production-builds.md index a9325da..0561274 100644 --- a/guides/deployment/production-builds.md +++ b/guides/deployment/production-builds.md @@ -136,6 +136,20 @@ config :volt, external: ~w(phoenix phoenix_html phoenix_live_view) Or per-build: `mix volt.build --external phoenix --external phoenix_html` +How an external is referenced depends on the output format, as in Rollup: + +| Format | Output for `import { Socket } from "phoenix"` | +| --- | --- | +| `:iife` (default) | `const { Socket } = Phoenix;` — read from a global the page provides | +| `:esm` | `import { Socket } from "phoenix";` — resolved by the browser, for example through an import map | +| `:cjs` | `require("phoenix")` | + +For IIFE output the global name is derived from the specifier (`phoenix_html` becomes `PhoenixHtml`). Pass a map to name the globals yourself; the names are ignored for `:esm` and `:cjs`: + +```elixir +config :volt, external: %{"phoenix" => "Phoenix", "vue" => "Vue"} +``` + ## Module Preloading For code-split builds, the production manifest records static imports, dynamic imports, chunk-local CSS, and emitted assets. Use `Volt.Preload.tags/2` in your layout to preload the entry and its static chunk dependencies: diff --git a/guides/features/environment-variables.md b/guides/features/environment-variables.md index 8cef0b3..ab1c690 100644 --- a/guides/features/environment-variables.md +++ b/guides/features/environment-variables.md @@ -34,6 +34,19 @@ Multiple prefixes are also supported: config :volt, env_prefix: ["VOLT_", "PUBLIC_"] ``` +## TypeScript + +Volt's client types declare `import.meta.env` with `MODE`, `DEV`, `PROD`, and any other key as `string | boolean | undefined`. `mix igniter.install volt` adds them to `tsconfig.json`; to add them by hand, include `deps/volt/priv/types/client/**/*.d.ts`. + +Declare your own variables to get exact types and completion: + +```ts +// assets/env.d.ts +interface ImportMetaEnv { + readonly VOLT_API_URL: string +} +``` + ## File Loading Order Files are loaded in order, with later files overriding earlier ones: diff --git a/guides/features/features.md b/guides/features/features.md index 1867953..cbf00f4 100644 --- a/guides/features/features.md +++ b/guides/features/features.md @@ -176,6 +176,8 @@ Exclude packages the host page already provides: config :volt, external: ~w(phoenix phoenix_html phoenix_live_view) ``` +IIFE output reads externals from globals; `:esm` and `:cjs` output keeps them as imports. See [Production Builds](../deployment/production-builds.md#external-modules). + ## Source Maps Production builds write `.map` files by default. Use `sourcemap: :hidden` to write maps without the URL comment (for Sentry, Datadog, etc.), or `sourcemap: false` to skip. diff --git a/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index bd63492..3935bed 100644 --- a/guides/features/formatting-and-linting.md +++ b/guides/features/formatting-and-linting.md @@ -25,18 +25,35 @@ mix volt.js.format ### Configuration +Formatter options live under the `:volt` key of `.formatter.exs`, next to the plugin: + ```elixir -config :volt, :format, - print_width: 100, - semi: false, - single_quote: true, - trailing_comma: :none, - arrow_parens: :always +[ + plugins: [Volt.Formatter], + inputs: ["{config,lib,test}/**/*.{ex,exs}", "assets/**/*.{js,ts,jsx,tsx}"], + volt: [ + print_width: 100, + semi: false, + single_quote: true, + trailing_comma: :none, + arrow_parens: :always + ] +] ``` -All [oxfmt options](https://hexdocs.pm/oxc/OXC.Format.html) are supported. Falls back to `.oxfmtrc.json` if no Elixir config is set. +All [oxfmt options](https://hexdocs.pm/oxc/OXC.Format.html) are supported. Without a `:volt` key, options come from `.oxfmtrc.json` or `.prettierrc.json`. `mix format`, `mix volt.js.format`, and `mix volt.js.check` all read the same options. + +`mix format` formats the files matched by `:inputs`. `mix volt.js.format` and `mix volt.js.check` use the build source set; `:root`, `:sources`, and `:ignore` under the `:volt` key override it for formatting only: + +```elixir +[ + volt: [semi: false, sources: ["priv/ts/**/*.ts"], ignore: ["vendor/**"]] +] +``` -`:root`, `:sources`, and `:ignore` may also be set under `config :volt, :format` to override the build source set for formatting only. +> #### Upgrading {: .info} +> +> Formatter options used to live in `config :volt, :format`. That key now only holds the build output format (`:iife`, `:esm`, or `:cjs`), and a keyword list there raises with instructions to move it. ## Linting diff --git a/guides/introduction/getting-started.md b/guides/introduction/getting-started.md index a9695ac..e588964 100644 --- a/guides/introduction/getting-started.md +++ b/guides/introduction/getting-started.md @@ -9,8 +9,8 @@ mix igniter.install volt The installer: - Adds `{:volt, "~> 0.19"}` to `mix.exs` - Configures build settings in `config/config.exs` -- Adds format and lint config to `config/config.exs` -- Adds `Volt.Formatter` plugin to `.formatter.exs` +- Adds lint config to `config/config.exs` +- Adds the `Volt.Formatter` plugin and formatter options to `.formatter.exs` - Adds the `Volt.DevServer` plug to your endpoint - Configures Volt's automatic development watcher - Updates `assets.build` and `assets.deploy` aliases @@ -107,7 +107,8 @@ Add `Volt.Formatter` to `.formatter.exs`: "{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] ``` diff --git a/lib/mix/tasks/volt/build.ex b/lib/mix/tasks/volt/build.ex index be1f616..099df16 100644 --- a/lib/mix/tasks/volt/build.ex +++ b/lib/mix/tasks/volt/build.ex @@ -23,7 +23,8 @@ defmodule Mix.Tasks.Volt.Build do * `--sourcemap false` — skip source map generation * `--sourcemap hidden` — write `.map` files but omit `sourceMappingURL` comment * `--resolve-dir` — additional directory for bare specifier resolution (repeatable) - * `--external` — specifier to exclude from bundle (repeatable) + * `--external` — specifier to exclude from bundle (repeatable); read from a global + in `iife` output and kept as an import in `esm` and `cjs` output * `--name` — output base name (default: derived from entry filename) * `--no-hash` — stable filenames (no content hash) * `--no-code-splitting` — disable chunk splitting diff --git a/lib/mix/tasks/volt/install.ex b/lib/mix/tasks/volt/install.ex index 64bf414..5d3e252 100644 --- a/lib/mix/tasks/volt/install.ex +++ b/lib/mix/tasks/volt/install.ex @@ -17,8 +17,8 @@ if Code.ensure_loaded?(Igniter) do 2. Remove `config :esbuild` and `config :tailwind` blocks 3. Update `assets.setup`, `assets.build`, and `assets.deploy` aliases 4. Add Volt build config to `config/config.exs` - 5. Add format and lint config to `config/config.exs` - 6. Add `Volt.Formatter` plugin to `.formatter.exs` + 5. Add lint config to `config/config.exs` + 6. Add `Volt.Formatter` plugin and formatter options 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 @@ -44,6 +44,7 @@ if Code.ensure_loaded?(Igniter) do alias Igniter.Project.Config, as: ProjectConfig alias Igniter.Project.Deps, as: ProjectDeps alias Igniter.Project.Formatter, as: ProjectFormatter + alias Sourceror.Zipper alias Volt.Paths alias Igniter.Project.TaskAliases @@ -66,9 +67,9 @@ if Code.ensure_loaded?(Igniter) do |> remove_old_watchers(app_name, endpoint) |> update_aliases() |> add_volt_config() - |> add_format_config() |> add_lint_config() |> add_formatter_plugin() + |> add_format_config() |> add_typescript_config() |> add_dev_config(app_name, endpoint) |> add_dev_server_plug() @@ -101,6 +102,7 @@ if Code.ensure_loaded?(Igniter) do files when is_list(files) <- Map.get(config, "files", []), true <- Enum.all?(files, &is_binary/1) do types = [ + "#{deps}/volt/priv/types/client/env.d.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" @@ -235,19 +237,27 @@ if Code.ensure_loaded?(Igniter) do opts ) - code = - format_kw - |> Enum.map(fn {key, value} -> [to_string(key), ": ", inspect(value)] end) - |> Enum.intersperse(",\n ") - |> IO.iodata_to_binary() - - ProjectConfig.configure( - igniter, - "config.exs", - :volt, - [:format], - {:code, Sourceror.parse_string!("[\n #{code}\n]")} - ) + # Runs after `add_formatter_plugin/1`, which creates `.formatter.exs`. + Igniter.update_elixir_file(igniter, ".formatter.exs", fn zipper -> + zipper + |> Zipper.down() + |> Zipper.rightmost() + |> CodeKeyword.put_in_keyword([:volt], format_kw, &{:ok, &1}) + |> case do + {:ok, zipper} -> + {:ok, zipper} + + _ -> + {:warning, + """ + Could not add Volt formatter options to `.formatter.exs`. + + Please add them manually, i.e + + volt: #{inspect(format_kw)} + """} + end + end) end defp add_lint_config(igniter) do diff --git a/lib/mix/tasks/volt/js/check.ex b/lib/mix/tasks/volt/js/check.ex index 04efd22..8b0bbd5 100644 --- a/lib/mix/tasks/volt/js/check.ex +++ b/lib/mix/tasks/volt/js/check.ex @@ -9,7 +9,7 @@ defmodule Mix.Tasks.Volt.Js.Check do mix volt.js.check mix volt.js.check --type-aware --type-check - Reads format options from `config :volt, :format` (falls back to `.oxfmtrc.json`). + Reads format options from the `:volt` key of `.formatter.exs` (falls back to `.oxfmtrc.json`). Lint settings come from `config :volt, :lint`. File discovery uses `config :volt, sources:` and `ignore:`. diff --git a/lib/mix/tasks/volt/js/format.ex b/lib/mix/tasks/volt/js/format.ex index d6889bf..d08e711 100644 --- a/lib/mix/tasks/volt/js/format.ex +++ b/lib/mix/tasks/volt/js/format.ex @@ -8,8 +8,9 @@ defmodule Mix.Tasks.Volt.Js.Format do mix volt.js.format - Reads options from `config :volt, :format`. Falls back to `.oxfmtrc.json`. - File discovery uses `config :volt, sources:` and `ignore:`. + Reads options from the `:volt` key of `.formatter.exs`. Falls back to `.oxfmtrc.json`. + File discovery uses `config :volt, sources:` and `ignore:`, which `:root`, + `:sources`, and `:ignore` under the same `:volt` key override. No Node.js required. """ diff --git a/lib/volt/application.ex b/lib/volt/application.ex index c613cc5..b1c20d9 100644 --- a/lib/volt/application.ex +++ b/lib/volt/application.ex @@ -12,13 +12,15 @@ defmodule Volt.Application do Volt.HMR.ModuleGraph.create_table() Volt.HMR.Errors.create_table() + # Watchers release their Tailwind contexts when they terminate, so the + # Tailwind processes start before them and stop after them. children = [ {Registry, keys: :duplicate, name: Volt.HMR.Registry}, - {Registry, keys: :unique, name: Volt.Dev.WatcherRegistry}, - {DynamicSupervisor, strategy: :one_for_one, name: Volt.Dev.WatcherSupervisor}, {Registry, keys: :unique, name: Volt.Tailwind.Registry}, {DynamicSupervisor, strategy: :one_for_one, name: Volt.Tailwind.WorkerSupervisor}, - Volt.Tailwind.Runtime + Volt.Tailwind.Runtime, + {Registry, keys: :unique, name: Volt.Dev.WatcherRegistry}, + {DynamicSupervisor, strategy: :one_for_one, name: Volt.Dev.WatcherSupervisor} ] opts = [strategy: :one_for_one, name: Volt.Supervisor] diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index 65e3ffc..d3dfb84 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -46,8 +46,10 @@ defmodule Volt.Builder do chunks: %{"vendor" => ["vue", "vue-router"], "ui" => ["assets/src/components"]} * `:write_manifest` — write `manifest.json` after building (default: `true`) - * `:external` — specifiers to exclude from the bundle and access as globals. - Accepts a list (global name auto-derived) or a map of `specifier => global_name`: + * `:external` — specifiers to exclude from the bundle. With `format: :iife` + they are read from global variables; with `:esm` and `:cjs` the imports + stay in the output. Accepts a list (global name auto-derived) or a map of + `specifier => global_name`, whose names apply to `:iife` only: external: ["vue", "phoenix"] external: %{"vue" => "Vue", "phoenix" => "Phoenix"} @@ -121,11 +123,10 @@ defmodule Volt.Builder do rewrite_nonlocal_labels(compiled, collected.specifier_labels, collected.path_labels) output_ctx = %Volt.Builder.OutputContext{ - plugins: ctx.plugins, - external_set: ctx.external, - external_globals: ctx.external_globals, - workers: collected.workers, - worker_results: worker_results + output_externals(ctx, build_ctx) + | plugins: ctx.plugins, + workers: collected.workers, + worker_results: worker_results } out = %Volt.Builder.BuildContext{ @@ -246,11 +247,10 @@ defmodule Volt.Builder 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: %{} + output_externals(ctx, build_ctx) + | plugins: ctx.plugins, + workers: workers, + worker_results: %{} } out = %Volt.Builder.BuildContext{ @@ -309,11 +309,10 @@ defmodule Volt.Builder do compiled = %{compiled | artifacts: compiled.artifacts ++ worker_artifacts} output_ctx = %Volt.Builder.OutputContext{ - plugins: ctx.plugins, - external_set: ctx.external, - external_globals: ctx.external_globals, - workers: workers, - worker_results: worker_results + output_externals(ctx, build_ctx) + | plugins: ctx.plugins, + workers: workers, + worker_results: worker_results } out = %Volt.Builder.BuildContext{ @@ -915,6 +914,19 @@ defmodule Volt.Builder do |> Enum.sort_by(fn {source_root, _package_dir} -> -byte_size(source_root) end) end + # IIFE bundles read externals from global variables. ES module and CommonJS + # output keeps them as imports for the host to resolve, as Rollup does. + defp output_externals(ctx, build_ctx) do + if Keyword.get(build_ctx.bundle_opts, :format, :iife) == :iife do + %Volt.Builder.OutputContext{ + external_set: ctx.external, + external_globals: ctx.external_globals + } + else + %Volt.Builder.OutputContext{external_imports: ctx.external} + end + end + defp normalize_external(externals) when is_map(externals) do set = externals |> Map.keys() |> MapSet.new() {set, externals} diff --git a/lib/volt/builder/externals.ex b/lib/volt/builder/externals.ex index e8f0a94..419055d 100644 --- a/lib/volt/builder/externals.ex +++ b/lib/volt/builder/externals.ex @@ -1,5 +1,5 @@ defmodule Volt.Builder.Externals do - @moduledoc "Rewrite external imports into global variable access for production builds." + @moduledoc "Rewrite external imports into global variable access for IIFE production builds." @doc """ Rewrite external import declarations into direct global access. diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index 5ce655e..ef3d8c1 100644 --- a/lib/volt/builder/output.ex +++ b/lib/volt/builder/output.ex @@ -20,7 +20,11 @@ defmodule Volt.Builder.Output do js_files = Rewriter.rewrite_external_imports(js_files, ctx) entry_label = entry |> Path.basename() |> Naming.file_path() entry_name = Path.rootname(entry_label) - bundle_opts = Keyword.put(bundle_opts, :entry, entry_label) + + bundle_opts = + bundle_opts + |> Keyword.put(:entry, entry_label) + |> put_external_imports(Rewriter.preserved_external_imports(js_files, ctx)) case bundle_js_files(js_files, bundle_opts) do {:ok, bundle_result} -> @@ -90,7 +94,11 @@ defmodule Volt.Builder.Output do js_files = Rewriter.rewrite_external_imports(js_files, ctx) entry_label = entry |> Path.basename() |> Naming.file_path() - bundle_opts = Keyword.put(bundle_opts, :entry, entry_label) + + bundle_opts = + bundle_opts + |> Keyword.put(:entry, entry_label) + |> put_external_imports(Rewriter.preserved_external_imports(js_files, ctx)) case bundle_js_files(js_files, bundle_opts) do {:ok, bundle_result} -> @@ -176,7 +184,11 @@ defmodule Volt.Builder.Output do sourcemap: Keyword.get(bundle_opts, :sourcemap, false), treeshake: Keyword.get(bundle_opts, :treeshake, false), define: Keyword.get(bundle_opts, :define, %{}), - external: Keyword.get(bundle_opts, :external, []), + external: + Enum.uniq( + Keyword.get(bundle_opts, :external, []) ++ + Rewriter.preserved_external_imports(js_files, ctx) + ), module_types: Keyword.get(bundle_opts, :module_types, %{}), output: [ entry_file_names: shared_entry_file_names(build_ctx.hash), @@ -544,7 +556,6 @@ defmodule Volt.Builder.Output do process_chunks( chunk_bundles, graph, - js_map, module_labels, css_results, ctx, @@ -576,7 +587,6 @@ defmodule Volt.Builder.Output do defp process_chunks( chunk_bundles, graph, - js_map, module_labels, css_results, ctx, @@ -587,9 +597,7 @@ defmodule Volt.Builder.Output do Map.new(chunk_bundles, fn {chunk_id, {code, sourcemap}} -> chunk = graph.chunks[chunk_id] - chunk_js = select_chunk_files(chunk.modules, js_map, module_labels) chunk_import_map = chunk_import_map(chunk, graph, module_labels, dep_map) - code = Rewriter.inject_external_preamble(code, chunk_js, ctx) code = Rewriter.rewrite_chunk_imports(code, chunk_import_map, chunk_url_map) code = Rewriter.rewrite_dynamic_preloads(code, preload_map) @@ -793,6 +801,7 @@ defmodule Volt.Builder.Output do bundle_opts |> Keyword.put(:entry, chunk_entry_label(chunk_js)) |> put_external_imports(external) + |> put_external_imports(Rewriter.preserved_external_imports(chunk_js, ctx)) case bundle_js_files(chunk_js, bundle_opts) do {:ok, result} -> diff --git a/lib/volt/builder/output_context.ex b/lib/volt/builder/output_context.ex index 1cc6d7e..db285e4 100644 --- a/lib/volt/builder/output_context.ex +++ b/lib/volt/builder/output_context.ex @@ -4,6 +4,7 @@ defmodule Volt.Builder.OutputContext do defstruct plugins: [], external_set: MapSet.new(), external_globals: %{}, + external_imports: MapSet.new(), workers: %{}, worker_results: %{} end diff --git a/lib/volt/builder/rewriter.ex b/lib/volt/builder/rewriter.ex index b856944..8379021 100644 --- a/lib/volt/builder/rewriter.ex +++ b/lib/volt/builder/rewriter.ex @@ -14,6 +14,28 @@ defmodule Volt.Builder.Rewriter do end end + @doc "Specifiers imported by `js_files` that stay as imports in the bundle output." + def preserved_external_imports(js_files, ctx) do + if MapSet.size(ctx.external_imports) == 0 do + [] + else + js_files + |> Enum.flat_map(fn {_label, code} -> + case OXC.select(code, "module.js", :import_specifiers) do + {:ok, specifiers} -> specifiers + {:error, _} -> [] + end + end) + |> Enum.uniq() + |> Enum.filter(&preserved_external?(&1, ctx.external_imports)) + end + end + + defp preserved_external?(specifier, externals) do + MapSet.member?(externals, specifier) or + Enum.any?(externals, &String.starts_with?(specifier, &1 <> "/")) + end + def external_chunk_imports(js_files, chunk_import_map) do js_files |> Enum.flat_map(fn {_label, code} -> diff --git a/lib/volt/config.ex b/lib/volt/config.ex index 97a70d3..c63efa3 100644 --- a/lib/volt/config.ex +++ b/lib/volt/config.ex @@ -130,20 +130,18 @@ defmodule Volt.Config do @spec build(atom() | nil, keyword()) :: map() def build(profile, overrides) do - flat_env = - Application.get_all_env(:volt) - |> Keyword.take(@build_keys) - |> Keyword.reject(fn {k, v} -> k == :format and not is_atom(v) end) + flat_env = Application.get_all_env(:volt) |> Keyword.take(@build_keys) profile_env = if profile do - Application.get_env(:volt, profile, []) - |> Keyword.take(@build_keys) - |> Keyword.reject(fn {k, v} -> k == :format and not is_atom(v) end) + Application.get_env(:volt, profile, []) |> Keyword.take(@build_keys) else [] end + reject_formatter_options!(flat_env[:format]) + reject_formatter_options!(profile_env[:format]) + config = @defaults |> Map.from_struct() @@ -156,6 +154,23 @@ defmodule Volt.Config do %{config | aliases: Map.merge(tsconfig_paths, config.aliases)} end + @doc false + def reject_formatter_options!(format) when is_list(format) do + raise ArgumentError, """ + formatter options are no longer read from `config :volt, :format`, which now \ + only holds the build output format (`:iife`, `:esm`, or `:cjs`). + + Move them to the `:volt` key of .formatter.exs: + + [ + plugins: [Volt.Formatter], + volt: #{inspect(format)} + ] + """ + end + + def reject_formatter_options!(_format), do: :ok + @doc """ Read dev server config, merged with defaults. diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index d327d91..c6c45aa 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -162,8 +162,8 @@ defmodule Volt.DevServer do %Volt.Dev.Session.Tables{} = tables <- Volt.Dev.tables(session) do call_generation(conn, config, tables) else - {:error, _reason} -> - conn |> Conn.send_resp(503, "Development session is unavailable") |> Conn.halt() + {:error, reason} -> + session_unavailable(conn, reason) end end @@ -172,8 +172,8 @@ defmodule Volt.DevServer do %Volt.Dev.Session.Tables{} = tables -> call_generation(conn, config, tables) - {:error, _} -> - conn |> Conn.send_resp(503, "Development session is unavailable") |> Conn.halt() + {:error, reason} -> + session_unavailable(conn, reason) end end @@ -186,6 +186,20 @@ defmodule Volt.DevServer do do_call(conn, config) end + defp session_unavailable(conn, reason) do + message = "Development session is unavailable: #{session_error(reason)}" + Logger.error("[Volt] " <> message) + conn |> Conn.send_resp(503, message) |> Conn.halt() + end + + defp session_error(:session_configuration_conflict) do + "a Volt watcher is already running for this asset root with different options. " <> + "`plug Volt.DevServer` starts its own watcher, so remove any " <> + "`Mix.Tasks.Volt.Dev` entry from the endpoint's `:watchers` in config/dev.exs." + end + + defp session_error(reason), do: inspect(reason) + defp call_generation(conn, config, tables) do do_call(conn, %{config | tables: tables}) rescue @@ -472,55 +486,80 @@ defmodule Volt.DevServer do file_path = Volt.Plugin.EmbeddedModule.parent_path(module_id) source = File.read!(file_path) - 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( - file_path, - result.code, - config.tables || config.session - ) - - Volt.HMR.StyleDependencies.update_from_compile( - file_path, - source, - result, - config.tables || config.session - ) - - result = rewrite_dev_css_urls(result, file_path, config) - mod_url = Volt.URL.join(config.prefix, relative) - code = code_for_request(result, mod_url, content_type, css_import?) - graph_url = if css_import?, do: URL.append_query(mod_url, "import"), else: mod_url - - update_module_graph( - graph_url, - graph_url, - file_path, - code, - source, - content_type, - config.tables || config.session - ) - - entry = %Volt.DevServer.CacheEntry{ - code: code, - sourcemap: result.sourcemap, - css: result.css, - hashes: result.hashes, - content_type: content_type - } - - Volt.Cache.put(cache_key, mtime, entry, config.tables || config.session) - send_compiled(conn, code, result.sourcemap, content_type) - + with {:ok, module_source, cacheable?} <- module_source(file_path, source, config), + {:ok, result} <- + Volt.Pipeline.compile(module_id, module_source, pipeline_opts(config, module_id)) do + 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( + file_path, + result.code, + config.tables || config.session + ) + + Volt.HMR.StyleDependencies.update_from_compile( + file_path, + source, + result, + config.tables || config.session + ) + + result = rewrite_dev_css_urls(result, file_path, config) + mod_url = Volt.URL.join(config.prefix, relative) + code = code_for_request(result, mod_url, content_type, css_import?) + graph_url = if css_import?, do: URL.append_query(mod_url, "import"), else: mod_url + + update_module_graph( + graph_url, + graph_url, + file_path, + code, + source, + content_type, + config.tables || config.session + ) + + entry = %Volt.DevServer.CacheEntry{ + code: code, + sourcemap: result.sourcemap, + css: result.css, + hashes: result.hashes, + content_type: content_type + } + + if cacheable?, + do: Volt.Cache.put(cache_key, mtime, entry, config.tables || config.session) + + send_compiled(conn, code, result.sourcemap, content_type) + else {:error, errors} -> send_compile_error(conn, file_path, errors, config) end end + # CommonJS/UMD files are converted to ES modules, with the modules they + # require bundled in. Those are not tracked for invalidation, so a module + # that requires others is rebuilt on every request instead of cached. + defp module_source(file_path, source, config) do + if Volt.JS.CommonJS.commonjs?(source, file_path) do + opts = [ + modules: Enum.reject([config.node_modules | config.resolve_dirs], &is_nil/1), + module_types: config.module_types + ] + + with {:ok, code} <- Volt.JS.CommonJS.to_esm(file_path, opts) do + {:ok, code, not requires_modules?(source, file_path)} + end + else + {:ok, source, true} + end + end + + defp requires_modules?(source, file_path) do + not match?({:ok, []}, OXC.select(source, Path.basename(file_path), :require_calls)) + end + defp pipeline_opts(config, importer) do [ target: config.target, @@ -888,32 +927,41 @@ defmodule Volt.DevServer do # ── Vendor pre-bundling ─────────────────────────────────────────── defp prebundle_vendor(root, node_modules, plugins, resolve_dirs, module_types) do - case Volt.JS.Vendor.prebundle( - root: root, - node_modules: node_modules, - plugins: plugins, - resolve_dirs: resolve_dirs, - module_types: module_types - ) do - {:ok, vendor_map} when map_size(vendor_map) > 0 -> - count = map_size(vendor_map) - Logger.debug("[Volt] Pre-bundled #{count} vendor package(s)") - - _ -> - :ok - end + Volt.JS.Vendor.prebundle( + root: root, + node_modules: node_modules, + plugins: plugins, + resolve_dirs: resolve_dirs, + module_types: module_types + ) end defp serve_vendor(specifier, config, browser_hash) do vendor_opts = vendor_opts(config) if Volt.JS.Vendor.current_browser_hash?(browser_hash, vendor_opts) do - read_or_bundle_vendor(specifier, config, vendor_opts) + with {:ok, code} <- read_or_bundle_vendor(specifier, config, vendor_opts) do + {:ok, version_vendor_imports(code, Volt.JS.Vendor.browser_hash(vendor_opts))} + end else {:error, :outdated} end end + # Pre-bundles import their siblings and shared chunks relatively. Application + # modules import the same files with the browser hash, and a browser treats + # URLs that differ only in their query as separate module instances. + defp version_vendor_imports(code, browser_hash) do + case OXC.rewrite_specifiers(code, "vendor.js", fn specifier -> + if NPM.Resolution.PackageResolver.relative?(specifier), + do: {:rewrite, URL.append_query(specifier, "v=#{browser_hash}")}, + else: :keep + end) do + {:ok, rewritten} -> rewritten + {:error, _} -> code + end + end + defp read_or_bundle_vendor(specifier, config, vendor_opts) do case Volt.JS.Vendor.read(specifier, vendor_opts) do {:ok, _} = ok -> diff --git a/lib/volt/formatter.ex b/lib/volt/formatter.ex index c57acb9..bd09fb3 100644 --- a/lib/volt/formatter.ex +++ b/lib/volt/formatter.ex @@ -17,8 +17,15 @@ defmodule Volt.Formatter do ## Configuration - Reads options from `config :volt, :format` or falls back to - `.oxfmtrc.json` / `.prettierrc.json`. See the "Formatter & linter configuration" section in the README for details. + Set oxfmt options under the `:volt` key of the same file: + + [ + plugins: [Volt.Formatter], + volt: [semi: false, single_quote: true, print_width: 100] + ] + + Without that key, options come from `.oxfmtrc.json` / `.prettierrc.json`. + See the "Formatting and Linting" guide for details. """ @behaviour Mix.Tasks.Format @@ -31,7 +38,7 @@ defmodule Volt.Formatter do @impl true def format(contents, opts) do filename = opts[:file] || extension_to_filename(opts[:extension]) || "input.ts" - format_opts = Volt.JS.Format.load_config() + format_opts = Volt.JS.Format.load_config(opts) OXC.Format.run!(contents, filename, format_opts) end diff --git a/lib/volt/js/common_js.ex b/lib/volt/js/common_js.ex new file mode 100644 index 0000000..6b83d95 --- /dev/null +++ b/lib/volt/js/common_js.ex @@ -0,0 +1,103 @@ +defmodule Volt.JS.CommonJS do + @moduledoc """ + Detects local CommonJS/UMD modules and converts them to ES modules for the dev server. + + Production builds hand CommonJS files to the bundler, which wraps them itself. + The dev server serves each file as a native ES module, so a CommonJS file such + as Phoenix's vendored `topbar.js` needs the same interop before a browser can + `import topbar from "../vendor/topbar"`. + """ + + @esm_declarations [ + :import_declaration, + :export_named_declaration, + :export_default_declaration, + :export_all_declaration + ] + + @doc """ + Return whether the module at `path` is CommonJS/UMD rather than an ES module. + + `.cjs` and `.cts` files are CommonJS by extension. A `.js` file is CommonJS + when it has no `import` or `export` declarations and uses `module.exports`, + `exports`, or `require`. + """ + @spec commonjs?(String.t(), String.t()) :: boolean() + def commonjs?(source, path) do + ext = Path.extname(path) + + cond do + ext in Volt.JS.Extensions.cjs() -> true + ext == ".js" -> commonjs_source?(source, Path.basename(path)) + true -> false + end + end + + defp commonjs_source?(source, filename) do + case OXC.parse(source, filename) do + {:ok, ast} -> not esm?(ast) and uses_commonjs?(ast) + {:error, _} -> false + end + end + + @doc """ + Bundle the CommonJS file at `path` into an ES module whose default export is + `module.exports`. Modules it requires are bundled along with it. + + ## Options + + * `:modules` — directories used to resolve bare `require` specifiers + * `:module_types` — bundler module type overrides + """ + @spec to_esm(String.t(), keyword()) :: {:ok, String.t()} | {:error, term()} + def to_esm(path, opts \\ []) do + module_types = Keyword.get(opts, :module_types, %{}) + + bundle_opts = + [ + cwd: Path.dirname(path), + format: :esm, + conditions: Volt.JS.Resolution.browser_conditions(), + modules: Keyword.get(opts, :modules, []), + define: %{"process.env.NODE_ENV" => ~s("development")} + ] ++ if(module_types != %{}, do: [module_types: module_types], else: []) + + case OXC.bundle(path, bundle_opts) do + {:ok, %{code: code}} -> {:ok, code} + {:ok, code} when is_binary(code) -> {:ok, code} + {:error, _} = error -> error + end + end + + defp esm?(%{body: body}) when is_list(body) do + Enum.any?(body, &(&1[:type] in @esm_declarations)) + end + + defp esm?(_ast), do: false + + defp uses_commonjs?(ast) do + {_ast, found?} = + OXC.postwalk(ast, false, fn + node, false -> {node, commonjs_node?(node)} + node, true -> {node, true} + end) + + found? + end + + defp commonjs_node?(node) when is_map(node) do + Volt.JS.AST.member_expression?(node, "module", "exports") or + exports_member?(node) or + match?({:ok, _}, Volt.JS.AST.call_arguments(node, "require")) + end + + defp commonjs_node?(_node), do: false + + defp exports_member?(%{ + type: :member_expression, + object: %{type: :identifier, name: "exports"} + }), + do: true + + defp exports_member?(_node), do: false +end diff --git a/lib/volt/js/discovery.ex b/lib/volt/js/discovery.ex index 9a4305d..aeff5b7 100644 --- a/lib/volt/js/discovery.ex +++ b/lib/volt/js/discovery.ex @@ -39,10 +39,6 @@ defmodule Volt.JS.Discovery do defp discovery_config(nil), do: [] - defp discovery_config(tool) do - config = Application.get_env(:volt, tool, []) - - # The bundler reads :format as an atom, so it holds no discovery keys. - if Keyword.keyword?(config), do: config, else: [] - end + defp discovery_config(:format), do: Volt.JS.Format.discovery_config() + defp discovery_config(tool), do: Application.get_env(:volt, tool, []) end diff --git a/lib/volt/js/extensions.ex b/lib/volt/js/extensions.ex index 6717b7f..86b8673 100644 --- a/lib/volt/js/extensions.ex +++ b/lib/volt/js/extensions.ex @@ -9,12 +9,16 @@ defmodule Volt.JS.Extensions do def js, do: @js def cjs, do: @cjs + def json, do: @json def node_resolvable, do: @js ++ @cjs ++ @json def node_resolvable_with_exact, do: ["" | node_resolvable()] def bundleable, do: @js ++ @cjs def formattable, do: @js - def compilable(plugins \\ []), do: plugin_exts(plugins, :compile) ++ @js ++ @css ++ @json + + def compilable(plugins \\ []), + do: plugin_exts(plugins, :compile) ++ @js ++ @cjs ++ @css ++ @json + def scannable(plugins \\ []), do: plugin_exts(plugins, :scan) ++ @js def resolvable(plugins \\ []), do: ["" | @js ++ @cjs ++ plugin_exts(plugins, :resolve) ++ @json] def resolvable_index, do: Enum.map(@js ++ @cjs, &("/index" <> &1)) diff --git a/lib/volt/js/format.ex b/lib/volt/js/format.ex index 0df0bb9..51058c7 100644 --- a/lib/volt/js/format.ex +++ b/lib/volt/js/format.ex @@ -26,18 +26,52 @@ defmodule Volt.JS.Format do @atom_values ~w(trailing_comma arrow_parens end_of_line quote_props object_wrap experimental_operator_position embedded_language_formatting)a @discovery_keys ~w(root sources ignore)a - def load_config do - opts = Application.get_env(:volt, :format) + @dot_formatter ".formatter.exs" - # The bundler reads the same key, where the value is an atom such as :esm. - # Only a keyword list carries formatter options. - if Keyword.keyword?(opts) do - Keyword.drop(opts, @discovery_keys) + @doc """ + Load oxfmt options. + + Options come from the `:volt` key of `.formatter.exs`: + + [ + plugins: [Volt.Formatter], + volt: [semi: false, single_quote: true] + ] + + Without that key, they come from `.oxfmtrc.json` or `.prettierrc.json`. + `formatter_opts` is the keyword list `.formatter.exs` evaluates to; `mix format` + passes it to `Volt.Formatter`, and the Mix tasks read the file themselves. + """ + @spec load_config(keyword()) :: keyword() + def load_config(formatter_opts \\ formatter_opts()) do + Volt.Config.reject_formatter_options!(Application.get_env(:volt, :format)) + + case Keyword.fetch(formatter_opts, :volt) do + {:ok, opts} -> Keyword.drop(opts, @discovery_keys) + :error -> load_json_config() + end + end + + @doc "Read the options in the project's `.formatter.exs`, or `[]` without one." + @spec formatter_opts() :: keyword() + def formatter_opts do + if File.regular?(@dot_formatter) do + {opts, _binding} = Code.eval_file(@dot_formatter) + opts else - load_json_config() + [] end end + @doc """ + File-discovery options (`:root`, `:sources`, `:ignore`) for `mix volt.js.format` + and `mix volt.js.check`, from the `:volt` key of `.formatter.exs`. + """ + @spec discovery_config(keyword()) :: keyword() + def discovery_config(formatter_opts \\ formatter_opts()) do + formatter_opts |> Keyword.get(:volt, []) |> Keyword.take(@discovery_keys) + end + def load_json_config do case find_json_config() do nil -> [] diff --git a/lib/volt/js/tsconfig.ex b/lib/volt/js/tsconfig.ex index 363ef31..7e75258 100644 --- a/lib/volt/js/tsconfig.ex +++ b/lib/volt/js/tsconfig.ex @@ -15,7 +15,9 @@ defmodule Volt.JS.TSConfig do %{"@" => "/absolute/path/to/src"} Glob suffixes (`/*`) are stripped from both keys and values. - Only the first path in each mapping array is used. + The first path in each mapping array that is not a declaration file is used; + mappings that only point at declaration files (`.d.ts`) describe types and + are left to normal module resolution. """ @spec read_paths(String.t()) :: %{String.t() => String.t()} def read_paths(tsconfig_path) do @@ -26,24 +28,29 @@ defmodule Volt.JS.TSConfig do tsconfig_dir = Path.dirname(tsconfig_path) base = Path.expand(base_url, tsconfig_dir) - Map.new(paths, fn {key, targets} -> - alias_key = key |> String.trim_trailing("/*") |> String.trim_trailing("*") - alias_key = String.trim_trailing(alias_key, "/") - - target = - targets - |> List.first("") - |> String.trim_trailing("/*") - |> String.trim_trailing("*") - |> String.trim_trailing("/") - - {alias_key, Path.expand(target, base)} - end) + for {key, targets} <- paths, + target = Enum.find(List.wrap(targets), &runtime_target?/1), + into: %{} do + {trim_glob(key), Path.expand(trim_glob(target), base)} + end else _ -> %{} end end + defp runtime_target?(target) when is_binary(target) do + not String.ends_with?(target, [".d.ts", ".d.mts", ".d.cts"]) + end + + defp runtime_target?(_target), do: false + + defp trim_glob(pattern) do + pattern + |> String.trim_trailing("/*") + |> String.trim_trailing("*") + |> String.trim_trailing("/") + end + @doc """ Find and read tsconfig.json paths from the current working directory. """ diff --git a/lib/volt/js/vendor.ex b/lib/volt/js/vendor.ex index 09de73e..388af78 100644 --- a/lib/volt/js/vendor.ex +++ b/lib/volt/js/vendor.ex @@ -196,7 +196,11 @@ defmodule Volt.JS.Vendor do if not force and Enum.all?(specifiers, &cache_fresh?(&1, module_dirs, plugins, module_types)) do {:ok, vendor_map} else - safe_bundle_vendors(specifiers, module_dirs, plugins, module_types, vendor_map) + with {:ok, bundled} = ok <- + safe_bundle_vendors(specifiers, module_dirs, plugins, module_types, vendor_map) do + Logger.debug("[Volt] Pre-bundled #{map_size(bundled)} vendor package(s)") + ok + end end end diff --git a/lib/volt/pipeline.ex b/lib/volt/pipeline.ex index 5637125..7460b8d 100644 --- a/lib/volt/pipeline.ex +++ b/lib/volt/pipeline.ex @@ -50,7 +50,7 @@ defmodule Volt.Pipeline do Volt.MIME.css?(content_type) -> compile_css(compile_path, source, opts) - ext in Volt.JS.Extensions.js() -> + ext in Volt.JS.Extensions.bundleable() -> compile_js(compile_path, source, opts) Volt.CSS.Modules.css_module?(base_path) -> diff --git a/lib/volt/test/lines.ex b/lib/volt/test/lines.ex index 97ededa..390242f 100644 --- a/lib/volt/test/lines.ex +++ b/lib/volt/test/lines.ex @@ -7,55 +7,120 @@ defmodule Volt.Test.Lines do at useful file lines without hand-parsing JavaScript. """ + @doc """ + Return the source line of every test in `source`, in the order the runtime + registers them. + + `test.each` and `describe.each` register one test per case, so their lines + repeat once per case. Returns `{:error, :dynamic_cases}` when the cases are + not an array literal and the number of tests cannot be read from the source. + """ @spec test_lines(String.t(), String.t()) :: {:ok, [pos_integer()]} | {:error, term()} def test_lines(source, filename) do - case OXC.parse(source, filename) do - {:ok, ast} -> - {:ok, ast |> collect_test_starts() |> Enum.sort() |> Enum.map(&line(source, &1))} - - {:error, _} = error -> - error + with {:ok, ast} <- OXC.parse(source, filename), + {:ok, groups} <- collect_test_groups(ast) do + {:ok, lines(source, flatten_groups(groups))} end end - defp collect_test_starts(ast) do - {_ast, starts} = - OXC.postwalk(ast, [], fn - %{type: :call_expression, callee: callee, arguments: args} = node, starts + # A group is `{anchor, starts}`: the offset of a test declaration and the + # offsets of the tests it registers, in registration order. Post-order + # traversal reaches the tests inside a `describe.each` body before the + # `describe.each` call, which then folds their groups into one repeated group. + defp collect_test_groups(ast) do + {_ast, acc} = + OXC.postwalk(ast, {:ok, []}, fn + %{type: :call_expression, callee: callee, arguments: args} = node, {:ok, groups} when is_list(args) -> - if test_callee?(callee) and test_call?(args) do - {node, [node.start | starts]} - else - {node, starts} - end - - node, starts -> - {node, starts} + {node, add_test_group(node, callee, args, groups)} + + node, acc -> + {node, acc} end) - starts + acc + end + + defp add_test_group(node, callee, args, groups) do + cond do + test_callee?(callee) and test_call?(args) -> + {:ok, [{node.start, [node.start]} | groups]} + + each_callee?(callee, &test_callee?/1) and test_call?(args) -> + with {:ok, count} <- case_count(callee) do + {:ok, [{node.start, List.duplicate(node.start, count)} | groups]} + end + + each_callee?(callee, &describe_callee?/1) -> + with {:ok, count} <- case_count(callee) do + {inner, outer} = + Enum.split_with(groups, fn {anchor, _starts} -> + anchor > node.start and anchor < node.end + end) + + starts = inner |> flatten_groups() |> List.duplicate(count) |> Enum.concat() + {:ok, [{node.start, starts} | outer]} + end + + true -> + {:ok, groups} + end + end + + defp flatten_groups(groups) do + groups |> Enum.sort() |> Enum.flat_map(fn {_anchor, starts} -> starts end) + end + + @test_names ["test", "it"] + @describe_names ["describe"] + + defp test_callee?(callee), do: callee_named?(callee, @test_names) + defp describe_callee?(callee), do: callee_named?(callee, @describe_names) + + # `test`, `test.skip`, `test.todo`, and the same for `it` and `describe`. + defp callee_named?(%{type: :identifier, name: name}, names), do: name in names + + defp callee_named?(%{type: :member_expression, object: object, property: property}, names) do + property[:name] in ["skip", "todo"] and callee_named?(object, names) end - defp test_callee?(%{type: :identifier, name: name}) when name in ["test", "it"], do: true + defp callee_named?(_callee, _names), do: false - defp test_callee?(%{type: :member_expression, object: object, property: %{name: property}}) - when property in ["skip", "todo"] do - test_callee?(object) + # `test.each(cases)` and `describe.each(cases)`, whose result is then called + # with the name and body. + defp each_callee?( + %{type: :call_expression, callee: %{type: :member_expression} = callee}, + base? + ) do + callee.property[:name] == "each" and base?.(callee.object) end - defp test_callee?(_), do: false + defp each_callee?(_callee, _base?), do: false + + defp case_count(%{arguments: [%{type: :array_expression, elements: elements}]}), + do: {:ok, length(elements)} + + defp case_count(_each_call), do: {:error, :dynamic_cases} defp test_call?([%{value: name} | _]) when is_binary(name), do: true defp test_call?([%{type: :template_literal, expressions: [], quasis: [_]} | _]), do: true defp test_call?(_), do: false - defp line(source, start) do - source - |> binary_part(0, start) - |> count_newlines(1) - end + # Counts each newline once for all offsets. Counting from the start of the + # source per test is quadratic and dominated the cost on large files. + defp lines(source, starts) do + newlines = for {offset, _length} <- :binary.matches(source, "\n"), do: offset - defp count_newlines(<<>>, line), do: line - defp count_newlines(<>, line), do: count_newlines(rest, line + 1) - defp count_newlines(<<_byte, rest::binary>>, line), do: count_newlines(rest, line) + {line_by_start, _rest, _line} = + starts + |> Enum.uniq() + |> Enum.sort() + |> Enum.reduce({%{}, newlines, 1}, fn start, {lines, newlines, line} -> + {before, rest} = Enum.split_while(newlines, &(&1 < start)) + line = line + length(before) + {Map.put(lines, start, line), rest, line} + end) + + Enum.map(starts, &Map.fetch!(line_by_start, &1)) + end end diff --git a/lib/volt/test/shared.ex b/lib/volt/test/shared.ex index d8eb238..42312d9 100644 --- a/lib/volt/test/shared.ex +++ b/lib/volt/test/shared.ex @@ -3,15 +3,20 @@ defmodule Volt.Test.Shared do alias Volt.Test.Config + @doc """ + Add source lines to collected tests. + + Lines are read from the source and matched to tests by position. Tests keep + no line when the source declares a different number of tests than the runtime + collected, for example when they are registered in a loop. + """ def add_source_lines(path, tests) do with {:ok, source} <- File.read(path), - {:ok, lines} <- Volt.Test.Lines.test_lines(source, Path.basename(path)) do - {:ok, - tests - |> Enum.zip(lines) - |> Enum.map(fn {test, line} -> %{test | line: line} end)} + {:ok, lines} <- Volt.Test.Lines.test_lines(source, Path.basename(path)), + true <- length(lines) == length(tests) do + {:ok, Enum.zip_with(tests, lines, fn test, line -> %{test | line: line} end)} else - {:error, _} -> {:ok, tests} + _ -> {:ok, tests} end end diff --git a/lib/volt/watcher.ex b/lib/volt/watcher.ex index b732eef..38756e3 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -63,7 +63,8 @@ defmodule Volt.Watcher do tailwind_dirs: [], reload_dirs: [], explicit_ignored: [], - watch_ignored: [] + watch_ignored: [], + source_digests: %{} ] def start_link(opts) do @@ -164,6 +165,8 @@ defmodule Volt.Watcher do watch_ignored: watch_ignored } + state = %{state | source_digests: initial_source_digests(all_dirs -- [root], state)} + if config[:tailwind], do: initial_tailwind_build(state) {:ok, refresh_tailwind_inputs(state)} @@ -321,8 +324,17 @@ defmodule Volt.Watcher do def handle_info({:rebuild, path}, state) do state = %{state | pending: Map.delete(state.pending, path)} - handle_js_change(path, state) - {:noreply, state} + digest = source_digest(path) + + # Generators such as Phoenix colocated hooks rewrite files with identical + # content on every code reload; rebuilding for those would reload the page, + # which triggers the next rewrite. + if Map.fetch(state.source_digests, path) == {:ok, digest} do + {:noreply, state} + else + handle_js_change(path, state) + {:noreply, %{state | source_digests: Map.put(state.source_digests, path, digest)}} + end end def handle_info(:tailwind_rebuild, state) do @@ -372,6 +384,43 @@ defmodule Volt.Watcher do Enum.any?(patterns, &GlobEx.match?(&1, path)) end + defp source_digest(path) do + case File.read(path) do + {:ok, source} -> :crypto.hash(:sha256, source) + {:error, _reason} -> :missing + end + end + + # Directories outside the asset root hold generated sources whose first + # rewrite would otherwise have no baseline to compare against. + defp initial_source_digests(dirs, state) do + extensions = Extensions.watchable_js(state.config[:plugins] || []) + + for dir <- dirs, + path <- source_files(dir, extensions, state), + into: %{}, + do: {path, source_digest(path)} + end + + defp source_files(dir, extensions, state) do + case File.ls(dir) do + {:ok, names} -> + Enum.flat_map(names, fn name -> + path = Path.join(dir, name) + + cond do + ignored_path?(path, state) -> [] + File.dir?(path) -> source_files(path, extensions, state) + Path.extname(path) in extensions -> [path] + true -> [] + end + end) + + {:error, _reason} -> + [] + end + end + defp schedule_rebuild(state, path) do case Map.get(state.pending, path) do nil -> :ok diff --git a/lib/volt/watcher/ignore.ex b/lib/volt/watcher/ignore.ex index 59a3ad1..906f78f 100644 --- a/lib/volt/watcher/ignore.ex +++ b/lib/volt/watcher/ignore.ex @@ -10,9 +10,13 @@ defmodule Volt.Watcher.Ignore do ] @spec compile([String.t()], [String.t()]) :: [GlobEx.t()] - def compile(patterns, roots), do: compile_patterns(@default_patterns ++ patterns, roots) + def compile(patterns, roots), + do: compile_patterns(@default_patterns, roots) ++ compile_explicit(patterns, roots) - def compile_explicit(patterns, roots), do: compile_patterns(patterns, roots) + # Configured patterns also resolve from the project directory, so that + # `_build/**` matches a watch root that itself lives inside `_build`. + def compile_explicit(patterns, roots), + do: compile_patterns(patterns, Enum.uniq([File.cwd!() | roots])) defp compile_patterns(patterns, roots) do patterns diff --git a/mix.exs b/mix.exs index aee828d..58427bf 100644 --- a/mix.exs +++ b/mix.exs @@ -68,6 +68,10 @@ defmodule Volt.MixProject do defp aliases do [ lint: [ + # `mix format` loads Volt.Formatter from the existing build and only + # compiles when the module is missing, so a cached build would format + # with stale plugin code. + "compile", "format --check-formatted", "volt.js.check --type-aware --type-check", "credo --strict", diff --git a/priv/types/client/env.d.ts b/priv/types/client/env.d.ts new file mode 100644 index 0000000..5ef2d2d --- /dev/null +++ b/priv/types/client/env.d.ts @@ -0,0 +1,10 @@ +interface ImportMetaEnv { + readonly MODE: string + readonly DEV: boolean + readonly PROD: boolean + readonly [key: string]: string | boolean | undefined +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/test/mix/tasks/volt/install_test.exs b/test/mix/tasks/volt/install_test.exs index 744d377..74b913c 100644 --- a/test/mix/tasks/volt/install_test.exs +++ b/test/mix/tasks/volt/install_test.exs @@ -110,6 +110,16 @@ defmodule Mix.Tasks.Volt.InstallTest do refute config_content =~ "config :esbuild" refute config_content =~ "config :tailwind" + refute config_content =~ "semi:" + + {formatter_opts, _binding} = + igniter.rewrite.sources[".formatter.exs"] + |> Rewrite.Source.get(:content) + |> Code.eval_string() + + assert Volt.Formatter in formatter_opts[:plugins] + assert formatter_opts[:volt][:semi] == false + assert formatter_opts[:volt][:trailing_comma] == :none dev_content = igniter.rewrite.sources["config/dev.exs"] @@ -160,6 +170,7 @@ defmodule Mix.Tasks.Volt.InstallTest do assert config["files"] == [ "assets/app.ts", + "deps/volt/priv/types/client/env.d.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" diff --git a/test/volt/application_test.exs b/test/volt/application_test.exs new file mode 100644 index 0000000..e99e7cf --- /dev/null +++ b/test/volt/application_test.exs @@ -0,0 +1,22 @@ +defmodule Volt.ApplicationTest do + use ExUnit.Case, async: true + + test "Tailwind processes start before watchers, so they stop after them" do + # `Supervisor.which_children/1` lists children in reverse start order. + started = + Volt.Supervisor + |> Supervisor.which_children() + |> Enum.map(fn {id, _pid, _type, _modules} -> id end) + |> Enum.reverse() + + position = fn id -> Enum.find_index(started, &(&1 == id)) end + + for tailwind <- [ + Volt.Tailwind.Registry, + Volt.Tailwind.WorkerSupervisor, + Volt.Tailwind.Runtime + ] do + assert position.(tailwind) < position.(Volt.Dev.WatcherSupervisor) + end + end +end diff --git a/test/volt/builder/code_splitting_test.exs b/test/volt/builder/code_splitting_test.exs index 7a73045..dde1043 100644 --- a/test/volt/builder/code_splitting_test.exs +++ b/test/volt/builder/code_splitting_test.exs @@ -2,6 +2,35 @@ defmodule Volt.Builder.CodeSplittingTest do use Volt.TestSupport.BuilderCase describe "build/1 code splitting" do + test "IIFE chunks read external globals once, inside the function scope" do + File.write!(Path.join(@fixture_dir, "src/global_lazy.js"), """ + import { Presence } from 'phoenix' + export const run = () => console.log(Presence) + """) + + File.write!(Path.join(@fixture_dir, "src/global_entry.js"), """ + import { Socket } from 'phoenix' + console.log(new Socket('/socket')) + import('./global_lazy.js').then((m) => m.run()) + """) + + {:ok, _result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/global_entry.js"), + outdir: @outdir, + hash: false, + minify: false, + sourcemap: false, + external: ["phoenix"] + ) + + for file <- Path.wildcard(Path.join(@outdir, "global_entry*.js")) do + js = File.read!(file) + assert [_once] = Regex.scan(~r/= Phoenix;/, js) + refute String.starts_with?(js, "const ") + end + end + test "multi-entry ESM builds share common chunks" do File.write!( Path.join(@fixture_dir, "src/shared.ts"), diff --git a/test/volt/builder/resolution_test.exs b/test/volt/builder/resolution_test.exs index 3063683..345b55e 100644 --- a/test/volt/builder/resolution_test.exs +++ b/test/volt/builder/resolution_test.exs @@ -442,6 +442,70 @@ defmodule Volt.Builder.ResolutionTest do assert js =~ "ref(" end + test "keeps external imports in ES module output" do + File.write!(Path.join(@fixture_dir, "src/external_app.js"), """ + import { Socket } from 'phoenix' + import 'phoenix/side-effect' + console.log(new Socket('/socket')) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/external_app.js"), + outdir: @outdir, + format: :esm, + minify: false, + sourcemap: false, + external: %{"phoenix" => "Phoenix"} + ) + + js = File.read!(result.js.path) + assert js =~ ~r/import \{ Socket \} from "phoenix"/ + assert js =~ ~s(import "phoenix/side-effect") + refute js =~ "= Phoenix" + end + + test "requires external imports in CommonJS output" do + File.write!(Path.join(@fixture_dir, "src/external_cjs_app.js"), """ + import { Socket } from 'phoenix' + console.log(new Socket('/socket')) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/external_cjs_app.js"), + outdir: @outdir, + format: :cjs, + minify: false, + sourcemap: false, + external: ["phoenix"] + ) + + js = File.read!(result.js.path) + assert js =~ ~s[require("phoenix")] + refute js =~ "= Phoenix" + end + + test "reads external imports from globals in IIFE output" do + File.write!(Path.join(@fixture_dir, "src/external_iife_app.js"), """ + import { Socket } from 'phoenix' + console.log(new Socket('/socket')) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/external_iife_app.js"), + outdir: @outdir, + minify: false, + sourcemap: false, + external: %{"phoenix" => "PhoenixGlobal"} + ) + + js = File.read!(result.js.path) + assert js =~ "const { Socket } = PhoenixGlobal;" + refute js =~ ~s(from "phoenix") + end + test "resolves .js imports to .ts files when .js does not exist" do File.write!(Path.join(@fixture_dir, "src/utils.ts"), """ export const helper = 'ts-resolved' diff --git a/test/volt/dev_server/basic_test.exs b/test/volt/dev_server/basic_test.exs index c16a38a..b86c214 100644 --- a/test/volt/dev_server/basic_test.exs +++ b/test/volt/dev_server/basic_test.exs @@ -75,6 +75,28 @@ defmodule Volt.DevServer.BasicTest do assert opts.watcher_opts[:tailwind_key] == {:profile, :default, Path.expand(css)} end + @tag :tmp_dir + test "explains a conflicting watcher instead of answering a bare 503", %{tmp_dir: root} do + assets = Path.join(root, "assets") + File.mkdir_p!(assets) + File.write!(Path.join(assets, "app.ts"), "export const x = 1") + + config = Volt.DevServer.init(root: assets, watch: true) + on_exit(fn -> Volt.Dev.stop(config.session) end) + + conflicting = Keyword.put(config.watcher_opts, :watch_dirs, [root]) + assert {:ok, _watcher} = Volt.Dev.start(conflicting) + + log = + ExUnit.CaptureLog.capture_log(fn -> + conn = Plug.Test.conn(:get, "/assets/app.ts") |> Volt.DevServer.call(config) + assert conn.status == 503 + assert conn.resp_body =~ "Mix.Tasks.Volt.Dev" + end) + + assert log =~ "a Volt watcher is already running" + end + test "passes through non-asset requests for downstream HTML plugs" do opts = Volt.DevServer.init(root: Path.join(@fixture_dir, "src"), prefix: "/assets") @@ -169,8 +191,15 @@ defmodule Volt.DevServer.BasicTest do assert vendor_conn.status == 200 - assert [[_, chunk_path]] = - Regex.scan(~r{from "\./(chunks/[^"']+\.js)"}, vendor_conn.resp_body) + # Shared chunks carry the importer's browser hash, so a module reached from + # both application code and another pre-bundle stays a single instance. + [_, browser_hash] = String.split(vendor_url, "?v=") + + assert [[_, chunk_path, ^browser_hash]] = + Regex.scan( + ~r{from "\./(chunks/[^"'?]+\.js)\?v=([a-f0-9]+)"}, + vendor_conn.resp_body + ) chunk_conn = call_dev_server("/@vendor/#{chunk_path}", diff --git a/test/volt/dev_server/import_rewriting_test.exs b/test/volt/dev_server/import_rewriting_test.exs index fd46978..7f9452e 100644 --- a/test/volt/dev_server/import_rewriting_test.exs +++ b/test/volt/dev_server/import_rewriting_test.exs @@ -26,6 +26,61 @@ defmodule Volt.DevServer.ImportRewritingTest do refute conn.resp_body =~ Path.join(root, "value.ts") end + describe "CommonJS interop" do + test "serves a local UMD module as an ES module with a default export" do + File.mkdir_p!(Path.join(@fixture_dir, "src/vendor")) + + File.write!(Path.join(@fixture_dir, "src/vendor/topbar.js"), """ + (function (window, document) { + var topbar = { show: function () {} }; + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else { + this.topbar = topbar; + } + }.call(this, window, document)); + """) + + conn = call_dev_server("/assets/vendor/topbar.js") + assert conn.status == 200 + assert conn.resp_body =~ "export default" + end + + test "bundles modules required by a local CommonJS module" do + File.write!(Path.join(@fixture_dir, "src/cjs-dep.js"), "module.exports = { answer: 42 }") + + File.write!( + Path.join(@fixture_dir, "src/cjs-entry.js"), + "exports.answer = require('./cjs-dep').answer" + ) + + conn = call_dev_server("/assets/cjs-entry.js") + assert conn.status == 200 + assert conn.resp_body =~ "export default" + assert conn.resp_body =~ "answer: 42" + refute conn.resp_body =~ ~r/require\(['"]\.\/cjs-dep/ + end + + test "serves .cjs and .cts files as ES modules" do + File.write!(Path.join(@fixture_dir, "src/dep.cjs"), "module.exports = { answer: 42 }") + File.write!(Path.join(@fixture_dir, "src/typed.cts"), "const n: number = 7; export = { n }") + + for path <- ["/assets/dep.cjs", "/assets/typed.cts"] do + conn = call_dev_server(path) + assert conn.status == 200 + assert conn.resp_body =~ "export default" + end + end + + test "leaves ES modules untouched" do + File.write!(Path.join(@fixture_dir, "src/esm.js"), "export const module = { exports: 1 }") + + conn = call_dev_server("/assets/esm.js") + assert conn.status == 200 + refute conn.resp_body =~ "__commonJS" + end + end + describe "import rewriting" do test "rewrites relative imports to absolute paths" do File.write!(Path.join(@fixture_dir, "src/utils.ts"), "export const y = 1") diff --git a/test/volt/js/discovery_test.exs b/test/volt/js/discovery_test.exs index 3f0c0ba..7e8d293 100644 --- a/test/volt/js/discovery_test.exs +++ b/test/volt/js/discovery_test.exs @@ -29,11 +29,7 @@ defmodule Volt.JS.DiscoveryTest do end test "discovers tool-specific source sets", %{tmp_dir: tmp_dir} do - Application.put_env(:volt, :format, - root: tmp_dir, - sources: ["format/**/*.ts"], - ignore: [] - ) + write_formatter!(tmp_dir, volt: [root: tmp_dir, sources: ["format/**/*.ts"], ignore: []]) Application.put_env(:volt, :lint, root: tmp_dir, @@ -41,11 +37,13 @@ defmodule Volt.JS.DiscoveryTest do ignore: [] ) - assert Discovery.format_files() == [Path.join(tmp_dir, "format/source.ts")] - assert Discovery.files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] + File.cd!(tmp_dir, fn -> + assert Discovery.format_files() == [Path.join(tmp_dir, "format/source.ts")] + assert Discovery.files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] + end) end - test "a bundle format on the same key holds no discovery options", %{tmp_dir: tmp_dir} do + test "formatting uses the build source set without .formatter.exs options", %{tmp_dir: tmp_dir} do Application.put_env(:volt, :format, :esm) Application.put_env(:volt, :root, tmp_dir) Application.put_env(:volt, :sources, ["format/**/*.ts"]) @@ -57,8 +55,14 @@ defmodule Volt.JS.DiscoveryTest do ignore: [] ) - assert Discovery.format_files() == [Path.join(tmp_dir, "format/source.ts")] - assert Discovery.files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] + File.cd!(tmp_dir, fn -> + assert Discovery.format_files() == [Path.join(tmp_dir, "format/source.ts")] + assert Discovery.files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] + end) + end + + defp write_formatter!(dir, opts) do + File.write!(Path.join(dir, ".formatter.exs"), inspect(opts)) end defp restore_env(key, :error), do: Application.delete_env(:volt, key) diff --git a/test/volt/js/format_test.exs b/test/volt/js/format_test.exs index bc68de7..a0cd616 100644 --- a/test/volt/js/format_test.exs +++ b/test/volt/js/format_test.exs @@ -4,31 +4,58 @@ defmodule Volt.JS.FormatTest do alias Volt.JS.Format setup do - original_config = Application.get_env(:volt, :format) + original_config = Application.fetch_env(:volt, :format) on_exit(fn -> case original_config do - nil -> Application.delete_env(:volt, :format) - config -> Application.put_env(:volt, :format, config) + :error -> Application.delete_env(:volt, :format) + {:ok, config} -> Application.put_env(:volt, :format, config) end end) end - test "load_config/0 excludes file-discovery options from OXC formatter options" do - Application.put_env(:volt, :format, - root: ".", - sources: ["priv/ts/**/*.ts"], - ignore: ["vendor/**"], - semi: false, - print_width: 100 - ) + test "load_config/1 reads formatter options from the :volt key of .formatter.exs" do + formatter_opts = [ + plugins: [Volt.Formatter], + volt: [ + root: ".", + sources: ["priv/ts/**/*.ts"], + ignore: ["vendor/**"], + semi: false, + print_width: 100 + ] + ] - assert Format.load_config() == [semi: false, print_width: 100] + assert Format.load_config(formatter_opts) == [semi: false, print_width: 100] + + assert Format.discovery_config(formatter_opts) == [ + root: ".", + sources: ["priv/ts/**/*.ts"], + ignore: ["vendor/**"] + ] + end + + test "load_config/1 falls back to JSON config files without a :volt key" do + assert Format.load_config(plugins: [Volt.Formatter]) == Format.load_json_config() + end + + test "load_config/0 reads the project's .formatter.exs" do + assert Format.load_config()[:semi] == false end - test "load_config/0 ignores a bundle format set on the same key" do + test "load_config/1 is unaffected by the build output format" do Application.put_env(:volt, :format, :esm) - assert Format.load_config() == Format.load_json_config() + assert Format.load_config(volt: [semi: false]) == [semi: false] + end + + test "formatter options under config :volt, :format are rejected with guidance" do + Application.put_env(:volt, :format, semi: false) + + assert_raise ArgumentError, ~r/Move them to the `:volt` key of \.formatter\.exs/, fn -> + Format.load_config(volt: []) + end + + assert_raise ArgumentError, ~r/\.formatter\.exs/, fn -> Volt.Config.build() end end end diff --git a/test/volt/js/tsconfig_test.exs b/test/volt/js/tsconfig_test.exs index 9576d15..9ad3e84 100644 --- a/test/volt/js/tsconfig_test.exs +++ b/test/volt/js/tsconfig_test.exs @@ -69,6 +69,23 @@ defmodule Volt.JS.TSConfigTest do paths = Volt.JS.TSConfig.read_paths(tsconfig_path()) assert paths["~utils"] =~ "src/utils" end + + test "skips mappings that only point at declaration files" do + write_tsconfig!(%{ + "compilerOptions" => %{ + "paths" => %{ + "topbar" => ["./types/topbar.d.ts"], + "phoenix" => ["./types/phoenix.d.ts", "./vendor/phoenix.js"], + "@/*" => ["./src/*"] + } + } + }) + + paths = Volt.JS.TSConfig.read_paths(tsconfig_path()) + refute Map.has_key?(paths, "topbar") + assert paths["phoenix"] =~ "vendor/phoenix.js" + assert paths["@"] =~ "src" + end end defp tsconfig_path, do: Path.join(@fixture_dir, "tsconfig.json") diff --git a/test/volt/js/vendor_test.exs b/test/volt/js/vendor_test.exs index 7d1ba7b..45f8ff3 100644 --- a/test/volt/js/vendor_test.exs +++ b/test/volt/js/vendor_test.exs @@ -61,6 +61,18 @@ defmodule Volt.JS.VendorTest do assert Map.has_key?(vendor_map, "fake-lib") end + test "logs only when packages are bundled, not when the cache is fresh" do + opts = [root: Path.join(@fixture_dir, "src"), node_modules: @node_modules] + + assert ExUnit.CaptureLog.capture_log(fn -> + Volt.JS.Vendor.prebundle(Keyword.put(opts, :force, true)) + end) =~ + "Pre-bundled" + + refute ExUnit.CaptureLog.capture_log(fn -> Volt.JS.Vendor.prebundle(opts) end) =~ + "Pre-bundled" + end + test "caches bundled files on disk" do Volt.JS.Vendor.prebundle( root: Path.join(@fixture_dir, "src"), diff --git a/test/volt/test/lines_test.exs b/test/volt/test/lines_test.exs index b09fe70..2247d57 100644 --- a/test/volt/test/lines_test.exs +++ b/test/volt/test/lines_test.exs @@ -18,6 +18,37 @@ defmodule Volt.Test.LinesTest do assert Volt.Test.Lines.test_lines(source, "math.test.ts") == {:ok, [4, 6, 7]} end + test "repeats lines for each-table cases in registration order" do + source = ~TS""" + import { describe, test } from 'volt:test' + + test.each([ + [1, 2], + [2, 3], + [3, 4] + ])('adds %d', () => {}) + + describe.each(['a', 'b'])('suite %s', () => { + test('first', () => {}) + test('second', () => {}) + }) + + test('after', () => {}) + """ + + assert Volt.Test.Lines.test_lines(source, "each.test.ts") == + {:ok, [3, 3, 3, 10, 11, 10, 11, 14]} + end + + test "reports each-tables whose cases are not an array literal" do + source = ~TS""" + const cases = [1, 2] + test.each(cases)('case %d', () => {}) + """ + + assert Volt.Test.Lines.test_lines(source, "dynamic.test.ts") == {:error, :dynamic_cases} + end + test "ignores non-test calls" do source = ~TS""" const helper = { test() {} } diff --git a/test/volt/test/runner_test.exs b/test/volt/test/runner_test.exs index a6e3d5b..2e7b5ec 100644 --- a/test/volt/test/runner_test.exs +++ b/test/volt/test/runner_test.exs @@ -36,6 +36,50 @@ defmodule Volt.Test.RunnerTest do ]} = Runner.collect_file(path) end + test "collects every each-table case and the tests after it", %{tmp_dir: tmp_dir} do + path = + write!(tmp_dir, "each.test.ts", ~TS""" + import { test } from 'volt:test' + + test.each([ + [1], + [2], + [3] + ])('case %d', () => {}) + + test('after', () => {}) + """) + + assert {:ok, tests} = Runner.collect_file(path) + + assert Enum.map(tests, &{&1.full_name, &1.line}) == [ + {"case 1", 3}, + {"case 2", 3}, + {"case 3", 3}, + {"after", 9} + ] + end + + test "keeps tests registered in ways the source does not show", %{tmp_dir: tmp_dir} do + path = + write!(tmp_dir, "dynamic.test.ts", ~TS""" + import { test } from 'volt:test' + + const cases = [1, 2] + test.each(cases)('case %d', () => {}) + + for (const name of ['x', 'y']) { + test('loop ' + name, () => {}) + } + + test('after', () => {}) + """) + + assert {:ok, tests} = Runner.collect_file(path) + + assert Enum.map(tests, & &1.full_name) == ["case 1", "case 2", "loop x", "loop y", "after"] + end + test "collects skip todo and tag metadata", %{tmp_dir: tmp_dir} do path = write!(tmp_dir, "modifiers.test.ts", ~TS""" diff --git a/test/volt/watcher_test.exs b/test/volt/watcher_test.exs index ed8dbbf..82d9152 100644 --- a/test/volt/watcher_test.exs +++ b/test/volt/watcher_test.exs @@ -411,6 +411,60 @@ defmodule Volt.WatcherTest do GenServer.stop(pid) end + test "resolves relative watcher ignore globs from the project directory", %{ + watch_dir: watch_dir + } do + generated_dir = Path.join(File.cwd!(), "tmp/volt-watcher-generated") + entry = Path.join(generated_dir, "index.js") + File.mkdir_p!(generated_dir) + on_exit(fn -> File.rm_rf!(generated_dir) end) + + {:ok, pid} = + Volt.Watcher.start_link( + root: watch_dir, + watch_dirs: [generated_dir], + watch_ignored: ["tmp/volt-watcher-generated/**"], + name: :test_watcher_project_relative_ignore + ) + + state = send_file_event(pid, entry) + + refute Map.has_key?(state.pending, entry) + GenServer.stop(pid) + end + + test "does not reload when a generated file is rewritten with identical content", %{ + watch_dir: watch_dir + } do + Registry.register(Volt.HMR.Registry, :clients, nil) + + generated_dir = Path.join(watch_dir, "generated") + root = Path.join(watch_dir, "assets") + index = Path.join(generated_dir, "index.js") + File.mkdir_p!(generated_dir) + File.mkdir_p!(root) + File.write!(index, "export const hooks = {};") + + {:ok, pid} = + Volt.Watcher.start_link( + root: root, + watch_dirs: [generated_dir], + name: :test_watcher_identical_rewrite + ) + + send(pid, {:file_event, self(), {index, [:modified]}}) + refute_receive {:volt_hmr, :update, _}, 300 + + File.write!(index, "export const hooks = {changed: true};") + send(pid, {:file_event, self(), {index, [:modified]}}) + assert_receive {:volt_hmr, :update, %{changes: [:full]}}, 2000 + + send(pid, {:file_event, self(), {index, [:modified]}}) + refute_receive {:volt_hmr, :update, _}, 300 + + GenServer.stop(pid) + end + test "ignores dependency directories by default", %{watch_dir: watch_dir} do entry = Path.join([watch_dir, "node_modules", "example", "index.ts"])