defmodule NPM.Normalize do @moduledoc """ Normalizes package.json data. Applies npm's normalization rules: defaulting main, normalizing repository URLs, handling people fields, etc. """ @doc """ Normalizes a package.json data map. """ @spec normalize(map()) :: map() def normalize(data) do data |> normalize_main() |> normalize_repository() |> normalize_bugs() |> normalize_homepage() |> normalize_people() end @doc """ Normalizes the `main` field. Defaults to `index.js` if missing. """ @spec normalize_main(map()) :: map() def normalize_main(%{"main" => _} = data), do: data def normalize_main(data), do: Map.put(data, "main", "index.js") @doc """ Normalizes the `repository` field. Converts shorthand strings like `"github:user/repo"` to full objects. """ @spec normalize_repository(map()) :: map() def normalize_repository(%{"repository" => repo} = data) when is_binary(repo) do Map.put(data, "repository", parse_repo_shorthand(repo)) end def normalize_repository(data), do: data @doc """ Normalizes the `bugs` field from a string URL. """ @spec normalize_bugs(map()) :: map() def normalize_bugs(%{"bugs" => url} = data) when is_binary(url) do Map.put(data, "bugs", %{"url" => url}) end def normalize_bugs(data), do: data @doc """ Normalizes the `homepage` field — removes trailing slash. """ @spec normalize_homepage(map()) :: map() def normalize_homepage(%{"homepage" => url} = data) when is_binary(url) do Map.put(data, "homepage", String.trim_trailing(url, "/")) end def normalize_homepage(data), do: data @doc """ Normalizes people fields (author, maintainers, contributors). Converts shorthand `"Name "` strings to objects. """ @spec normalize_people(map()) :: map() def normalize_people(data) do data |> normalize_person("author") |> normalize_person_list("contributors") |> normalize_person_list("maintainers") end @doc """ Parses a person string like `"Name (url)"` into a map. """ @spec parse_person(String.t()) :: map() def parse_person(str) when is_binary(str) do {name, rest} = extract_name(str) {email, rest2} = extract_angle_bracket(rest) {url, _} = extract_paren(rest2) result = %{"name" => String.trim(name)} result = if email, do: Map.put(result, "email", email), else: result if url, do: Map.put(result, "url", url), else: result end def parse_person(data) when is_map(data), do: data defp normalize_person(data, field) do case data[field] do str when is_binary(str) -> Map.put(data, field, parse_person(str)) _ -> data end end defp normalize_person_list(data, field) do case data[field] do list when is_list(list) -> Map.put(data, field, Enum.map(list, &parse_person/1)) _ -> data end end defp parse_repo_shorthand(str) do cond do String.starts_with?(str, "github:") -> %{"type" => "git", "url" => "https://github.com/#{String.trim_leading(str, "github:")}"} String.starts_with?(str, "gitlab:") -> %{"type" => "git", "url" => "https://gitlab.com/#{String.trim_leading(str, "gitlab:")}"} String.starts_with?(str, "bitbucket:") -> %{ "type" => "git", "url" => "https://bitbucket.org/#{String.trim_leading(str, "bitbucket:")}" } String.contains?(str, "/") and not String.contains?(str, "://") -> %{"type" => "git", "url" => "https://github.com/#{str}"} true -> %{"type" => "git", "url" => str} end end defp extract_name(str) do case Regex.run(~r/^([^<(]+)/, str) do [_, name] -> {name, String.trim_leading(str, name)} _ -> {str, ""} end end defp extract_angle_bracket(str) do case Regex.run(~r/<([^>]+)>/, str) do [full, val] -> {val, String.trim_leading(str, full)} _ -> {nil, str} end end defp extract_paren(str) do case Regex.run(~r/\(([^)]+)\)/, str) do [full, val] -> {val, String.trim_leading(str, full)} _ -> {nil, str} end end end