Current section
Files
Jump to
Current section
Files
README.md
# Norma
[](https://hex.pm/packages/norma)
[](https://hexdocs.pm/norma)
[](https://hex.pm/packages/norma)
[](https://github.com/mazingstudio/norma/blob/master/LICENSE)
Normalize URLs to the format you need.
## Installation
Add `Norma` to your list of dependencies in `mix.exs`. Tracking the latest release is
recommended:
```elixir
def deps do
[
{:norma, ">= 0.0.0"}
]
end
```
If you prefer to pin the minor line:
```elixir
{:norma, "~> 2.0"}
```
Documentation is on [HexDocs](https://hexdocs.pm/norma).
### Which version do I want?
| | `2.0` | `1.9` |
| --- | --- | --- |
| Elixir | `~> 1.13` (tested 1.13, 1.16, 1.19, 1.20) | `~> 1.11` (tested 1.13 through 1.19) |
| Output | RFC 3986 normalized | as written since 2017 |
| Docs | this file | [README at `v1.9.0`](https://github.com/mazingstudio/norma/blob/v1.9.0/README.md) · [HexDocs](https://hexdocs.pm/norma/1.9.0) |
`2.0` requires Elixir 1.13 because it uses `URI.new/1`. On an older Elixir, pin
`{:norma, "~> 1.9"}` — it is still supported and its output is unchanged from `1.x`.
**What 2.0 introduces**
- **RFC 3986 normalization by default.** Dot segments are resolved (`/a/../b` → `/b`), the
host is lowercased, percent-encoding is normalized, and a port is dropped only when it is
the scheme's default. `8080` now survives; `http://…:443` keeps its port.
- **Query strings keep input order and duplicate keys.** `?tag=a&tag=b` round-trips instead
of being sorted and de-duplicated. `restore_old_query_behavior: true` brings `1.x` back.
- **`force_root_path` now clears query and fragment too** ([#7](https://github.com/mazingstudio/norma/issues/7)),
and a new `add_root_path` slashes only an empty path.
- **`add_trailing_slash` looks at the final path segment only**, so `/v1.2/docs` gets its slash.
- **`downcase_host` was removed** — RFC normalization lowercases the host unconditionally,
so the option could no longer be honored.
Full list with before/after strings: [Migrating to 2.0](docs/migrating-to-2.0.md) and the
[CHANGELOG](CHANGELOG.md).
### Note on compatibility
Norma leans heavily on the standard library's `URI` module, whose parsing behavior has
shifted across Elixir releases. If you hit a surprising result, [the module's
history](https://github.com/elixir-lang/elixir/commits/main/lib/elixir/lib/uri.ex) is usually
the fastest explanation.
## Usage
Two public functions. Both take a URL string and an optional map of options.
| Function | Returns |
| --- | --- |
| `Norma.normalize(url, opts \\ %{})` | `String.t()`; input it cannot parse as a URL is returned unchanged |
| `Norma.normalize_if_valid(url, opts \\ %{})` | `{:ok, String.t()}` or `{:error, "Not an URL."}` |
```elixir
iex> Norma.normalize("example.com")
"http://example.com"
iex> Norma.normalize_if_valid("example.com")
{:ok, "http://example.com"}
iex> Norma.normalize_if_valid("example")
{:error, "Not an URL."}
```
Use `normalize_if_valid/2` when the input is untrusted — user submissions, scraped text,
model output. Use `normalize/2` only when the value is already known to be a URL.
Options are a map. Every key defaults to `false`, and any subset may be combined.
| Option | Effect |
| --- | --- |
| `remove_scheme: true` | Drops `http://` / `https://` from the output |
| `remove_fragment: true` | Drops everything from `#` onward |
| `remove_www: true` | Drops a leading `www.` from the host |
| `add_trailing_slash: true` | Appends `/` to the path when the final segment is not file-like |
| `force_root_path: true` | Replaces the path with `/` and clears query + fragment |
| `add_root_path: true` | Sets path to `/` only when path is empty |
| `restore_old_query_behavior: true` | 1.x query sort + last-wins dedupe |
Host case is always lowercased (RFC 3986 normalize). The 1.x `downcase_host`
option was removed in 2.0 — see [Migrating to 2.0](docs/migrating-to-2.0.md).
```elixir
iex> Norma.normalize("https://example.com", %{remove_scheme: true})
"example.com"
iex> Norma.normalize("https://example.com#faqs", %{remove_fragment: true})
"https://example.com"
iex> Norma.normalize("https://www.example.com", %{remove_www: true})
"https://example.com"
iex> Norma.normalize("https://EXAMPLE.COM/FAQS")
"https://example.com/FAQS"
iex> Norma.normalize("https://example.com/docs", %{add_trailing_slash: true})
"https://example.com/docs/"
iex> Norma.normalize("https://example.com/docs", %{force_root_path: true})
"https://example.com/"
iex> Norma.normalize("//www.example.com:1337/test#test",
...> %{remove_fragment: true, force_root_path: true, remove_www: true})
"http://example.com:1337/"
```
### Behavior worth knowing before you rely on it
- A scheme-less input is assumed to be `http`. Norma never upgrades to `https`.
- Output is RFC 3986 normalized: dot segments are resolved (`/a/../b` → `/b`), the host is
lowercased, percent-encoding is normalized, and the port is dropped only when it is the
scheme's default (`https://…:443` loses it, `http://…:443` keeps it). `8080` is never
stripped.
- Query parameters keep their input order and duplicate keys, so `?tag=a&tag=b` round-trips.
Pass `restore_old_query_behavior: true` for the 1.x sort-and-dedupe.
- `force_root_path` replaces the path **and** clears query and fragment.
- `add_trailing_slash` looks for a `.` in the final path segment only, so `/v1.2/docs` gets
its slash.
- An input with no path stays pathless (`http://example.com`, not `http://example.com/`),
a deliberate deviation from RFC 3986 §6.2.3 that keeps `add_trailing_slash`,
`add_root_path` and `force_root_path` meaningful.
Every behavior above is pinned by the golden suite. See
[Migrating to 2.0](docs/migrating-to-2.0.md) for what changed since `1.x`.
### With Ecto
```elixir
def creation_changeset(params) do
norma_options = %{
remove_www: true,
force_root_path: true,
remove_fragment: true
}
%MyEntity{}
|> cast(params, @fields)
|> put_change(:url, Norma.normalize(params.url, norma_options))
end
```
## Contributing
### Adding options
1. Add support for the option in `/lib/norma/normalizer.ex`. Prefer pattern matching and
guards over `if`s and `case`s.
2. Add a test in `/test/norma_test.exs`.
3. Add documentation to the `README`.
4. Send a PR 🎉
### Maintainers
- [Zura Guerra](https://github.com/ZuraGuerra)
---
## A Mazing project (Studio Closed!)
Originally sponsored by _Mazing Studio_.