defmodule QuickBEAM.JS.Bundler do
@moduledoc false
alias QuickBEAM.JS.PackageResolver
@ts_extensions [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"]
@resolve_opts [extensions: @ts_extensions]
@spec bundle_file(String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
def bundle_file(entry_path, opts \\ []) do
entry_path = Path.expand(entry_path)
node_modules = Keyword.get(opts, :node_modules) || find_node_modules(entry_path)
project_root = project_root(entry_path, node_modules)
entry_label = relative_label(entry_path, project_root)
bundle_opts =
opts
|> Keyword.drop([:node_modules])
|> Keyword.put_new(:entry, entry_label)
case collect_modules(entry_path, project_root) do
{:ok, files} -> OXC.bundle(files, bundle_opts)
{:error, _} = error -> error
end
end
defp collect_modules(entry_path, project_root) do
case do_collect(entry_path, project_root, [], %{}) do
{:ok, files, _seen} -> {:ok, Enum.reverse(files)}
{:error, _} = error -> error
end
end
@spec do_collect(String.t(), String.t(), [{String.t(), String.t()}], %{String.t() => true}) ::
{:ok, [{String.t(), String.t()}], %{String.t() => true}} | {:error, term()}
defp do_collect(abs_path, project_root, files, seen) do
if Map.has_key?(seen, abs_path) do
{:ok, files, seen}
else
with {:ok, source} <- File.read(abs_path),
{:ok, rewritten, resolved_paths} <- rewrite_and_resolve(source, abs_path, project_root) do
label = relative_label(abs_path, project_root)
seen = Map.put(seen, abs_path, true)
files = [{label, rewritten} | files]
collect_deps(resolved_paths, project_root, files, seen)
else
{:error, reason} when is_atom(reason) -> {:error, {:file_read_error, abs_path, reason}}
{:error, _} = error -> error
end
end
end
@spec collect_deps([String.t()], String.t(), [{String.t(), String.t()}], %{String.t() => true}) ::
{:ok, [{String.t(), String.t()}], %{String.t() => true}} | {:error, term()}
defp collect_deps([], _project_root, files, seen), do: {:ok, files, seen}
defp collect_deps([path | rest], project_root, files, seen) do
case do_collect(path, project_root, files, seen) do
{:ok, files, seen} -> collect_deps(rest, project_root, files, seen)
{:error, _} = error -> error
end
end
defp rewrite_and_resolve(source, importer, project_root) do
Process.put(:bundler_resolved, [])
from_dir = Path.dirname(importer)
result =
OXC.rewrite_specifiers(source, Path.basename(importer), fn specifier ->
resolve_and_track(specifier, from_dir, project_root)
end)
resolved_paths = Process.delete(:bundler_resolved) || []
case result do
{:ok, rewritten} -> {:ok, rewritten, Enum.reverse(resolved_paths)}
{:error, errors} -> {:error, {:parse_error, importer, errors}}
end
catch
{:error, _} = error ->
Process.delete(:bundler_resolved)
error
end
defp resolve_and_track(specifier, from_dir, project_root) do
case PackageResolver.resolve(specifier, from_dir, @resolve_opts) do
{:builtin, _} ->
:keep
{:ok, resolved_path} ->
Process.put(:bundler_resolved, [resolved_path | Process.get(:bundler_resolved)])
if PackageResolver.relative?(specifier) do
:keep
else
{:rewrite, PackageResolver.relative_import_path(from_dir, resolved_path, project_root)}
end
:error ->
throw({:error, {:module_not_found, specifier, "could not resolve"}})
end
end
defp find_node_modules(entry_path) do
PackageResolver.find_node_modules(Path.dirname(entry_path))
end
defp relative_label(path, root) do
path
|> Path.relative_to(root)
|> Path.split()
|> Enum.join("/")
end
defp project_root(entry_path, nil), do: Path.dirname(entry_path)
defp project_root(entry_path, node_modules) do
[entry_path, node_modules]
|> Enum.map(&Path.split/1)
|> shared_segments()
|> Path.join()
end
defp shared_segments([first | rest]) do
first
|> Enum.with_index()
|> Enum.take_while(fn {segment, index} ->
Enum.all?(rest, &(Enum.at(&1, index) == segment))
end)
|> Enum.map(&elem(&1, 0))
end
end