Packages

Join URL path segments and normalize the result.

Current section

Files

Jump to
url_join src url_join.gleam
Raw

src/url_join.gleam

//// Join URL path segments and normalize the result.
////
//// A simple and clean library for combining URL segments into a properly
//// formatted URL. Handles protocols, slashes, query parameters, and hash
//// fragments automatically.
////
//// ## Example
////
//// ```gleam
//// import url_join
////
//// let url = url_join.join([
//// "https://example.com",
//// "api",
//// "v1",
//// "users",
//// "?page=1",
//// ])
//// // -> "https://example.com/api/v1/users?page=1"
//// ```
import gleam/list
import gleam/string
/// Join URL path segments and normalize the result.
///
/// This function takes a list of URL segments and combines them into a single,
/// properly normalized URL. It handles protocols, slashes, query strings, and
/// hash fragments intelligently.
///
/// ## Examples
///
/// ```gleam
/// join(["http://www.google.com", "a", "/b/cd", "?foo=123"])
/// // -> "http://www.google.com/a/b/cd?foo=123"
///
/// join(["https://example.com/", "/api/", "users"])
/// // -> "https://example.com/api/users"
///
/// join(["a", "b", "c"])
/// // -> "a/b/c"
/// ```
///
/// ## Behaviour
///
/// - Filters out empty segments
/// - Merges a leading bare protocol (e.g. `"http:"`) with the next segment
/// - Merges a leading `"/"` with the next segment
/// - Normalises protocols to `protocol://` (and `file:///` for file)
/// - Trims and collapses slashes between segments
/// - Does not add a slash before `?`, `#`, or `&`
/// - Normalises query string (multiple `?`/`&` become a single `?` then `&`)
///
pub fn join(parts: List(String)) -> String {
parts
|> list.filter(fn(part) { part != "" })
|> normalize
}
fn normalize(segments: List(String)) -> String {
case segments {
[] -> ""
[first, ..rest] ->
first
|> merge_leading_parts(rest)
|> process_parts
|> join_parts
|> normalize_query
}
}
// ============================================================================
// Merge Leading Parts
// ============================================================================
fn merge_leading_parts(first: String, rest: List(String)) -> List(String) {
case rest {
[] -> [normalize_protocol(first)]
[next, ..tail] -> {
case first == "/" || is_plain_protocol(first) {
True -> merge_leading_parts(first <> next, tail)
False -> [normalize_protocol(first), ..rest]
}
}
}
}
fn is_plain_protocol(s: String) -> Bool {
case string.split_once(s, on: ":") {
Ok(#(before, after)) ->
!string.contains(before, "/") && only_slashes_or_empty(after)
Error(_) -> False
}
}
fn only_slashes_or_empty(s: String) -> Bool {
string.replace(s, "/", "") == ""
}
// ============================================================================
// Protocol Normalization
// ============================================================================
fn normalize_protocol(s: String) -> String {
let is_ipv6 = string.starts_with(s, "[") && string.contains(s, "]")
let has_full_protocol = string.contains(s, "://")
case s {
"file:///" <> _ -> s
"file:" <> rest -> "file:///" <> drop_leading_slashes(rest)
_ ->
case is_ipv6 || has_full_protocol {
True -> s
False ->
case string.split_once(s, on: ":") {
Ok(#(protocol, rest)) ->
protocol <> "://" <> drop_leading_slashes(rest)
Error(_) -> s
}
}
}
}
// ============================================================================
// Slash Handling
// ============================================================================
fn drop_leading_slashes(s: String) -> String {
case string.starts_with(s, "/") {
True -> drop_leading_slashes(string.drop_start(s, 1))
False -> s
}
}
fn drop_trailing_slashes(s: String) -> String {
case string.ends_with(s, "/") {
True -> drop_trailing_slashes(string.drop_end(s, 1))
False -> s
}
}
fn collapse_trailing_slashes(s: String) -> String {
let trimmed = drop_trailing_slashes(s)
case string.ends_with(s, "/") && trimmed != "" {
True -> trimmed <> "/"
False -> trimmed
}
}
// ============================================================================
// Part Processing
// ============================================================================
fn process_parts(parts: List(String)) -> List(String) {
let len = list.length(parts)
parts
|> list.index_map(fn(part, index) {
let is_first = index == 0
let is_last = index == len - 1
let no_leading = case is_first {
True -> part
False -> drop_leading_slashes(part)
}
case is_last {
True -> collapse_trailing_slashes(no_leading)
False -> drop_trailing_slashes(no_leading)
}
})
|> list.filter(fn(p) { p != "" })
}
fn join_parts(parts: List(String)) -> String {
case parts {
[] -> ""
[first, ..rest] -> list.fold(rest, first, join_two_parts)
}
}
fn join_two_parts(prev: String, part: String) -> String {
case string.ends_with(prev, "?") || string.ends_with(prev, "#") {
True -> prev <> part
False -> prev <> "/" <> part
}
}
// ============================================================================
// Query Normalization
// ============================================================================
fn normalize_query(s: String) -> String {
s
|> string.replace("/?", "?")
|> string.replace("/#", "#")
|> string.replace("/&", "&")
|> normalize_query_params
}
fn normalize_query_params(s: String) -> String {
case string.split_once(s, on: "#") {
Ok(#(before_hash, after_hash)) -> {
let query = build_query(before_hash)
case after_hash {
"" -> query
_ -> query <> "#" <> after_hash
}
}
Error(_) -> build_query(s)
}
}
fn build_query(s: String) -> String {
case split_query_parts(s) {
[] -> s
[path] -> path
[path, ..params] -> path <> "?" <> string.join(params, "&")
}
}
fn split_query_parts(s: String) -> List(String) {
s
|> string.replace("?", "&")
|> string.split("&")
|> list.filter(fn(x) { x != "" })
}