diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index 3eea110..53887ed 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -615,8 +615,9 @@ defmodule Volt.Builder do # ── Module compilation ────────────────────────────────────────────── defp compile_all(modules, _target, ctx) do - with {:ok, compiled} <- compile_modules(modules, ctx) do - merge_compiled(compiled) + with {:ok, compiled} <- compile_modules(modules, ctx), + {:ok, merged} <- merge_compiled(compiled) do + {:ok, %{merged | package_manifests: Volt.Builder.PackageManifests.files(modules)}} end end diff --git a/lib/volt/builder/compiled.ex b/lib/volt/builder/compiled.ex index 35561d7..6293ceb 100644 --- a/lib/volt/builder/compiled.ex +++ b/lib/volt/builder/compiled.ex @@ -1,5 +1,5 @@ defmodule Volt.Builder.Compiled do @moduledoc "Compiled modules and prepared binary assets before bundling." - defstruct scripts: [], styles: [], assets: [], artifacts: [] + defstruct scripts: [], styles: [], assets: [], artifacts: [], package_manifests: [] end diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index ef3d8c1..9c08590 100644 --- a/lib/volt/builder/output.ex +++ b/lib/volt/builder/output.ex @@ -6,7 +6,12 @@ defmodule Volt.Builder.Output do @doc "Bundle modules into a single in-memory JS bundle." def bundle_single( entry, - %Volt.Builder.Compiled{scripts: js_files, styles: css_parts, assets: assets}, + %Volt.Builder.Compiled{ + scripts: js_files, + styles: css_parts, + assets: assets, + package_manifests: package_manifests + }, files, build_ctx ) do @@ -26,7 +31,7 @@ defmodule Volt.Builder.Output do |> Keyword.put(:entry, entry_label) |> put_external_imports(Rewriter.preserved_external_imports(js_files, ctx)) - case bundle_js_files(js_files, bundle_opts) do + case bundle_js_files(js_files ++ package_manifests, 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) @@ -79,7 +84,8 @@ defmodule Volt.Builder.Output do scripts: js_files, styles: css_parts, assets: assets, - artifacts: asset_artifacts + artifacts: asset_artifacts, + package_manifests: package_manifests }, build_ctx ) do @@ -100,7 +106,7 @@ defmodule Volt.Builder.Output do |> Keyword.put(:entry, entry_label) |> put_external_imports(Rewriter.preserved_external_imports(js_files, ctx)) - case bundle_js_files(js_files, bundle_opts) do + case bundle_js_files(js_files ++ package_manifests, 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) @@ -159,7 +165,8 @@ defmodule Volt.Builder.Output do scripts: js_files, styles: css_parts, assets: assets, - artifacts: asset_artifacts + artifacts: asset_artifacts, + package_manifests: package_manifests }, modules, path_labels, @@ -178,7 +185,7 @@ defmodule Volt.Builder.Output do bundle = OXC.Bundle.new( entries: shared_bundle_entries(entries, path_labels), - files: js_files, + files: js_files ++ package_manifests, format: Keyword.get(bundle_opts, :format, :esm), minify: Keyword.get(bundle_opts, :minify, false), sourcemap: Keyword.get(bundle_opts, :sourcemap, false), @@ -397,7 +404,8 @@ defmodule Volt.Builder.Output do scripts: js_files, styles: css_parts, assets: assets, - artifacts: asset_artifacts + artifacts: asset_artifacts, + package_manifests: package_manifests }, {modules, dep_map}, build_ctx @@ -421,6 +429,7 @@ defmodule Volt.Builder.Output do graph.chunks, js_map, module_labels, + package_manifests, bundle_opts, ctx, graph, @@ -781,7 +790,16 @@ defmodule Volt.Builder.Output do Path.relative_to(to_label, from_dir) end - defp build_chunk_bundles(chunks, js_map, module_labels, bundle_opts, ctx, graph, dep_map) do + defp build_chunk_bundles( + chunks, + js_map, + module_labels, + package_manifests, + bundle_opts, + ctx, + graph, + dep_map + ) do Enum.reduce_while(chunks, {:ok, %{}}, fn {chunk_id, chunk}, {:ok, acc} -> chunk_js = select_chunk_files(chunk.modules, js_map, module_labels) @@ -803,7 +821,7 @@ defmodule Volt.Builder.Output do |> put_external_imports(external) |> put_external_imports(Rewriter.preserved_external_imports(chunk_js, ctx)) - case bundle_js_files(chunk_js, bundle_opts) do + case bundle_js_files(chunk_js ++ package_manifests, bundle_opts) do {:ok, result} -> {code, sourcemap} = extract_bundle_result(result) code = Rewriter.restore_dynamic_imports(code, dynamic_import_placeholder) diff --git a/lib/volt/builder/package_manifests.ex b/lib/volt/builder/package_manifests.ex new file mode 100644 index 0000000..6cfcf6e --- /dev/null +++ b/lib/volt/builder/package_manifests.ex @@ -0,0 +1,53 @@ +defmodule Volt.Builder.PackageManifests do + @moduledoc """ + Carries the `sideEffects` field of bundled packages into the bundle input. + + Modules are bundled from a virtual project in which a package's files sit + under the package name (see `Volt.Builder.Naming.module_label/2`), without + the package's `package.json`. Rolldown reads `sideEffects` from the nearest + `package.json`, so without one it keeps every module that runs code at the + top level, such as each icon of an icon library wrapped in `forwardRef`. A + minimal manifest at the package root restores what the package declared. + """ + + @doc """ + Returns a `{label, json}` virtual file for each package of `modules` whose + `package.json` declares `sideEffects`. + """ + @spec files([{String.t(), String.t(), term()}]) :: [{String.t(), String.t()}] + def files(modules) do + modules + |> Enum.flat_map(fn {path, _label, _source} -> package_root(path) end) + |> Enum.uniq() + |> Enum.flat_map(&manifest_file/1) + end + + defp package_root(module_id) do + {path, _query} = Volt.URL.split_query(module_id) + + case path |> :binary.matches("/node_modules/") |> List.last() do + nil -> + [] + + {position, length} -> + root_end = position + length + name = package_name(binary_part(path, root_end, byte_size(path) - root_end)) + [{name, binary_part(path, 0, root_end) <> name}] + end + end + + defp package_name("@" <> _ = relative_path), + do: relative_path |> String.split("/") |> Enum.take(2) |> Enum.join("/") + + defp package_name(relative_path), do: relative_path |> String.split("/") |> hd() + + defp manifest_file({name, dir}) do + with {:ok, json} <- File.read(Path.join(dir, "package.json")), + {:ok, %{"sideEffects" => side_effects}} <- Jason.decode(json) do + manifest = Jason.encode!(%{"sideEffects" => side_effects}) + [{Volt.Builder.Naming.file_path(name) <> "/package.json", manifest}] + else + _ -> [] + end + end +end diff --git a/test/volt/builder/resolution_test.exs b/test/volt/builder/resolution_test.exs index 95ccaf0..9ba67c1 100644 --- a/test/volt/builder/resolution_test.exs +++ b/test/volt/builder/resolution_test.exs @@ -84,6 +84,60 @@ defmodule Volt.Builder.ResolutionTest do assert js =~ "b-" end + test "drops unused modules of packages declaring no side effects" do + for {package, side_effects} <- [{"pure-icons", "false"}, {"@scope/effectful-icons", "true"}] do + package_dir = Path.join(@fixture_dir, "node_modules/#{package}") + File.mkdir_p!(package_dir) + + File.write!( + Path.join(package_dir, "package.json"), + ~s({"name":"#{package}","type":"module","main":"index.js","sideEffects":#{side_effects}}) + ) + + File.write!(Path.join(package_dir, "wrap.js"), """ + export function wrap(render) { + globalThis.registered = (globalThis.registered || 0) + 1 + return { render } + } + """) + + File.write!(Path.join(package_dir, "index.js"), """ + export { default as Used } from './used.js' + export { default as Unused } from './unused.js' + """) + + File.write!(Path.join(package_dir, "used.js"), """ + import { wrap } from './wrap.js' + export default wrap(() => '#{package}-used-icon') + """) + + File.write!(Path.join(package_dir, "unused.js"), """ + import { wrap } from './wrap.js' + export default wrap(() => '#{package}-unused-icon') + """) + end + + File.write!(Path.join(@fixture_dir, "src/icons_app.ts"), """ + import { Used } from 'pure-icons' + import { Used as EffectfulUsed } from '@scope/effectful-icons' + console.log(Used, EffectfulUsed) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/icons_app.ts"), + outdir: @outdir, + minify: false, + sourcemap: false, + node_modules: Path.join(@fixture_dir, "node_modules") + ) + + js = File.read!(result.js.path) + assert js =~ "pure-icons-used-icon" + refute js =~ "pure-icons-unused-icon" + assert js =~ "@scope/effectful-icons-unused-icon" + end + test "resolves bare imports from the importer's scoped package directory" do app_packages = Path.join(@fixture_dir, "node_modules") framework_root = Path.join(@fixture_dir, "framework")