Skip to main content

lib/duskmoon_bundler/js/transforms/asset_urls.ex

defmodule DuskmoonBundler.JS.Transforms.AssetURLs do
  @moduledoc """
  Rewrites `new URL("./asset.ext", import.meta.url)` references to asset imports.

  Vite treats relative asset URL constructors as part of the module graph so
  production builds can copy, hash, and rewrite the referenced file. DuskmoonBundler does
  the same by converting the asset argument into a generated `?url` import before
  the normal import rewriting and bundling phases run.

  Only relative specifiers that point at known static asset extensions are
  rewritten. Absolute URLs, package URLs, and non-asset files are left unchanged.
  """

  alias DuskmoonBundler.JS.Patch

  @doc """
  Rewrites matching asset URL constructors in `source`.

  Returns the original source unchanged when parsing fails or no matching
  constructor is found.
  """
  @spec rewrite(String.t(), String.t()) :: String.t()
  def rewrite(source, filename) do
    case OXC.select(source, filename, :asset_urls) do
      {:ok, refs} ->
        rewrites = Enum.filter(refs, &relative_asset_specifier?(&1.specifier))
        if rewrites == [], do: source, else: apply_rewrites(source, rewrites)

      {:error, _} ->
        source
    end
  end

  defp relative_asset_specifier?(specifier) do
    {path, _query} = DuskmoonBundler.URL.split_query(specifier)

    (String.starts_with?(path, "./") or String.starts_with?(path, "../")) and
      DuskmoonBundler.Assets.asset?(path)
  end

  defp apply_rewrites(source, rewrites) do
    {imports, patches} =
      rewrites
      |> Enum.with_index()
      |> Enum.map_reduce([], fn {ref, index}, patches ->
        specifier = Patch.selector_specifier(ref)
        ident = "__duskmoon_bundler_asset_url_#{index}"

        import_line = [
          "import ",
          ident,
          " from ",
          Jason.encode!(DuskmoonBundler.URL.append_query(specifier, "url")),
          ";"
        ]

        {import_line, [Patch.replace_selector(ref, ident) | patches]}
      end)

    [Enum.intersperse(imports, "\n"), "\n", Patch.apply(source, patches)]
    |> IO.iodata_to_binary()
  end
end