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