Skip to main content

lib/npm/pack.ex

defmodule NPM.Pack do
  @moduledoc """
  Creates npm-compatible tarballs from local projects.

  Implements the `npm pack` functionality — reads `files` from package.json,
  applies default exclusions, and generates a publishable tarball.
  """

  @always_include ~w(package.json README.md README LICENSE LICENCE CHANGELOG)
  @always_exclude ~w(.git .svn .hg node_modules .npm .DS_Store)

  @doc """
  Lists files that would be included in the tarball.
  """
  @spec list_files(String.t()) :: {:ok, [String.t()]} | {:error, term()}
  def list_files(project_dir) do
    pkg_path = Path.join(project_dir, "package.json")

    case File.read(pkg_path) do
      {:ok, content} ->
        data = NPM.JSON.decode!(content)
        files = resolve_files(data, project_dir)
        {:ok, Enum.sort(files)}

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Generates the tarball filename from package.json data.
  """
  @spec tarball_name(map()) :: String.t()
  def tarball_name(%{"name" => name, "version" => version}) do
    safe_name = String.replace(name, "/", "-") |> String.trim_leading("@")
    "#{safe_name}-#{version}.tgz"
  end

  def tarball_name(%{"name" => name}), do: String.replace(name, "/", "-") <> "-0.0.0.tgz"

  @doc """
  Checks if a file should be excluded from the tarball.
  """
  @spec excluded?(String.t()) :: boolean()
  def excluded?(path) do
    basename = Path.basename(path)
    dirname = path |> Path.split() |> hd()

    basename in @always_exclude or dirname in @always_exclude or
      String.starts_with?(basename, ".")
  end

  @doc """
  Checks if a file should always be included regardless of `files` field.
  """
  @spec always_included?(String.t()) :: boolean()
  def always_included?(path) do
    basename = Path.basename(path)
    name_no_ext = Path.rootname(basename)
    name_upper = String.upcase(name_no_ext)

    Enum.any?(@always_include, fn pattern ->
      String.upcase(pattern) == name_upper or String.upcase(basename) == String.upcase(pattern)
    end)
  end

  @doc """
  Returns the default files list when no `files` field is specified.
  """
  @spec default_files(String.t()) :: [String.t()]
  def default_files(project_dir) do
    case File.ls(project_dir) do
      {:ok, entries} ->
        entries
        |> Enum.reject(&excluded?/1)
        |> Enum.flat_map(&expand_if_dir(project_dir, &1))
        |> Enum.sort()

      _ ->
        []
    end
  end

  defp resolve_files(%{"files" => patterns} = data, project_dir) when is_list(patterns) do
    explicit =
      patterns
      |> Enum.flat_map(&match_pattern(project_dir, &1))
      |> Enum.reject(&excluded?/1)

    always =
      case File.ls(project_dir) do
        {:ok, entries} -> Enum.filter(entries, &always_included?/1)
        _ -> []
      end

    main_file = main_entry(data)
    main_files = if main_file, do: [main_file], else: []

    (always ++ main_files ++ explicit)
    |> Enum.uniq()
    |> Enum.filter(&File.exists?(Path.join(project_dir, &1)))
  end

  defp resolve_files(_data, project_dir), do: default_files(project_dir)

  defp main_entry(%{"main" => main}) when is_binary(main), do: main
  defp main_entry(_), do: nil

  defp match_pattern(base, pattern) do
    full = Path.join(base, pattern)

    if File.dir?(full) do
      expand_if_dir(base, pattern)
    else
      case Path.wildcard(full) do
        [] -> []
        matches -> Enum.map(matches, &Path.relative_to(&1, base))
      end
    end
  end

  defp expand_if_dir(base, entry) do
    full = Path.join(base, entry)

    if File.dir?(full) do
      walk_files(full)
      |> Enum.map(&Path.relative_to(&1, base))
    else
      [entry]
    end
  end

  defp walk_files(dir) do
    case File.ls(dir) do
      {:ok, entries} -> Enum.flat_map(entries, &expand_path(dir, &1))
      _ -> []
    end
  end

  defp expand_path(dir, entry) do
    path = Path.join(dir, entry)
    if File.dir?(path), do: walk_files(path), else: [path]
  end
end