defmodule NPM.Resolution.Exports do @moduledoc """ Parse and resolve the `exports` field from `package.json`. Modern npm packages use the `exports` field (a.k.a. "export map") to define entry points and restrict access to internal modules. Supports: - String shorthand: `"exports": "./index.js"` - Subpath exports: `"exports": { ".": "./index.js", "./utils": "./lib/utils.js" }` - Conditional exports: `"exports": { "import": "./esm.js", "require": "./cjs.js" }` - Nested conditions: `"exports": { ".": { "import": "./esm.js", "default": "./cjs.js" } }` """ @type export_map :: String.t() | [export_map()] | %{String.t() => export_map()} | nil @doc """ Parse the exports field from a package.json map. Returns a normalized map of subpath → target mappings, or nil if no exports field. """ @spec parse(map()) :: %{String.t() => String.t() | map()} | nil def parse(%{"exports" => exports}) when is_binary(exports) do %{"." => exports} end def parse(%{"exports" => exports}) when is_map(exports) do if subpath_exports?(exports) do exports else %{"." => exports} end end def parse(_), do: nil @doc """ Resolve an import path against an export map. Given a subpath (e.g. `"."`, `"./utils"`) and a list of conditions (e.g. `["import", "default"]`), returns the resolved file path. """ @spec resolve(map(), String.t(), [String.t()]) :: {:ok, String.t()} | :error def resolve(export_map, subpath, conditions \\ ["default"]) do export_map |> candidates(subpath, conditions) |> Enum.find_value(:error, &{:ok, &1}) end @doc """ List all exported subpaths from an export map. """ @spec subpaths(map()) :: [String.t()] def subpaths(export_map) when is_map(export_map) do Map.keys(export_map) |> Enum.sort() end def subpaths(_), do: [] @doc """ Detect whether a package uses ESM (`type: "module"`) or CJS. """ @spec module_type(map()) :: :esm | :cjs def module_type(%{"type" => "module"}), do: :esm def module_type(_), do: :cjs @doc """ Checks if a subpath is exported by the export map. """ @spec exported?(String.t(), map() | nil) :: boolean() def exported?(_subpath, nil), do: false def exported?(subpath, export_map) when is_map(export_map) do Map.has_key?(export_map, subpath) or has_wildcard_match?(subpath, export_map) end def exported?(_, _), do: false @doc """ Extracts all conditions used in the export map. """ @spec conditions(map() | nil) :: [String.t()] def conditions(nil), do: [] def conditions(export_map) when is_map(export_map) do export_map |> Map.values() |> Enum.flat_map(&extract_conditions/1) |> Enum.uniq() |> Enum.sort() end @doc """ Validates that all export paths resolve to existing files. """ @spec validate(map() | nil, String.t()) :: {:ok, [String.t()]} | {:error, [String.t()]} def validate(nil, _base_dir), do: {:ok, []} def validate(export_map, base_dir) when is_map(export_map) do paths = collect_paths(export_map) missing = Enum.reject(paths, &File.exists?(Path.join(base_dir, &1))) if missing == [], do: {:ok, paths}, else: {:error, Enum.map(missing, &"#{&1} not found")} end defp subpath_exports?(map) do Map.keys(map) |> Enum.any?(&String.starts_with?(&1, ".")) end defp candidates(export_map, subpath, conditions) when is_map(export_map) do exact = Map.get(export_map, subpath) |> target_candidates(conditions) wildcard = export_map |> Enum.flat_map(fn {pattern, target} -> case wildcard_replacement(pattern, subpath) do nil -> [] replacement -> replace_wildcards(target_candidates(target, conditions), replacement) end end) exact ++ wildcard end defp candidates(_, _, _), do: [] defp target_candidates(nil, _conditions), do: [] defp target_candidates(path, _conditions) when is_binary(path), do: [path] defp target_candidates(list, conditions) when is_list(list), do: Enum.flat_map(list, &target_candidates(&1, conditions)) defp target_candidates(target, conditions) when is_map(target) do conditions |> Enum.flat_map(fn condition -> Map.get(target, condition) |> target_candidates(conditions) end) end defp replace_wildcards(paths, replacement) do Enum.map(paths, &String.replace(&1, "*", replacement)) end defp wildcard_replacement(pattern, subpath) do case String.split(pattern, "*", parts: 2) do [prefix, suffix] -> if String.starts_with?(subpath, prefix) and String.ends_with?(subpath, suffix) do subpath |> String.trim_leading(prefix) |> trim_suffix(suffix) end _ -> nil end end defp trim_suffix(value, ""), do: value defp trim_suffix(value, suffix), do: String.trim_trailing(value, suffix) defp has_wildcard_match?(subpath, export_map) do Enum.any?(export_map, fn {pattern, _} -> wildcard_matches?(subpath, pattern) end) end defp wildcard_matches?(subpath, pattern), do: wildcard_replacement(pattern, subpath) != nil defp extract_conditions(entry) when is_map(entry), do: Map.keys(entry) ++ Enum.flat_map(Map.values(entry), &extract_conditions/1) defp extract_conditions(entry) when is_list(entry), do: Enum.flat_map(entry, &extract_conditions/1) defp extract_conditions(_), do: ["default"] defp collect_paths(export_map) do export_map |> Map.values() |> Enum.flat_map(&collect_target_paths/1) |> Enum.uniq() end defp collect_target_paths(value) when is_binary(value), do: [value] defp collect_target_paths(value) when is_list(value), do: Enum.flat_map(value, &collect_target_paths/1) defp collect_target_paths(value) when is_map(value), do: value |> Map.values() |> Enum.flat_map(&collect_target_paths/1) defp collect_target_paths(_), do: [] end