Web

URL tools

URLs by the rules browsers use: parts and query as a map, relative links resolved, a normal form for comparing, tracking parameters stripped, query values set.

Version 1.0.0 · 5 functions · 1 crate

#urls#links#tracking#privacy#seo

Functions

What use url_tools.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use url_parse, url_join from url_tools (Quillo 0.6).

url_parse(url: str) → anyor an error

A URL's parts: { scheme, username, host, port, path, query, fragment, origin, href }, query a map of its parameters (the last value of a repeated one), port null when it is the scheme's default.

url_join(base: str, relative: str) → stror an error

A link resolved against the page it is on: url_join("https://a.com/blog/post", "../img/x.png") is https://a.com/img/x.png. An absolute relative comes back as it is.

url_strip_tracking(url: str) → stror an error

The URL without tracking parameters: every utm_*, and fbclid, gclid, msclkid, mc_eid, _ga, igshid and others ad networks add. The rest is left in its order.

url_normalize(url: str) → stror an error

One form for URLs that mean the same page, for comparing, deduplicating and cache keys: scheme and host in lower case (as parsing does), default port dropped, . and .. resolved, tracking parameters dropped, the rest sorted by name, the fragment dropped, and a lone / path kept.

url_with_query(url: str, params: map) → stror an error

The URL with query parameters set from params ({ page: 2, sort: "new" }): existing ones replaced in place, new ones added, a null value removing one.

Get it

$ quillo install url_tools               # url_tools.qwer into this folder$ quillo install url_tools --example     # and its example, url_tools.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/url_tools.qwer$ curl -O https://quillolang.org/plugins/files/url_tools.qwe

Then, in your program, all of it or only what you call:

main.qwe
use url_tools.qweruse url_parse, url_join from url_tools      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
url = "2"

Example

url_tools.qwe
// A link-sharing site: links cleaned of tracking before they are saved, deduplicated by their// normal form, and a page's relative links made absolute.////   quillo run url_tools.qwe      then open http://localhost:8080/share?url=https://Shop.Example.com:443/a/../shoes?utm_source=x%26color=red%26fbclid=abc%23reviewsuse "url_tools.qwer"route GET /share ? { url: str } {    let clean = url_strip_tracking(url)?    { saved: clean, key: url_normalize(url)?, host: url_parse(clean)?.host }}route GET /same = {    same: url_normalize("https://example.com/list?b=2&a=1#top")? == url_normalize("HTTPS://EXAMPLE.com:443/list?a=1&b=2&utm_medium=mail")?,    joined: url_join("https://example.com/blog/2026/post.html", "../../img/cover.jpg")?,    next_page: url_with_query("https://example.com/search?q=mugs&page=1&ref=nav", { page: 2, ref: null, sort: "price" })?,    parts: url_parse("https://ada@example.com:8443/a/b?x=1&x=2&y=%C3%A9#frag")?,    bad: url_parse("not a url").error,}
$ quillo run url_tools.qwe$ curl 'localhost:8080/share?url=https://Shop.Example.com:443/a/../shoes?utm_source=x%26color=red%26fbclid=abc%23reviews'$ curl 'localhost:8080/same'

Source

url_tools.qwer
//! URLs taken apart and put together by the WHATWG rules browsers use: parts and query as a map, relative links resolved, a normal form for comparing and caching, tracking parameters (utm_*, fbclid, gclid...) stripped, and query values set.//! version: 1.0.0//!//! [dependencies]//! url = "2"use serde_json::{json, Map, Value};use std::collections::BTreeMap;const TRACKING: [&str; 24] = [    "fbclid", "gclid", "gclsrc", "dclid", "gbraid", "wbraid", "msclkid", "mc_cid", "mc_eid", "_ga", "_gl", "igshid", "yclid", "twclid",    "ttclid", "li_fat_id", "mkt_tok", "oly_anon_id", "oly_enc_id", "_hsenc", "_hsmi", "vero_id", "rb_clickid", "s_cid",];fn parse(u: &str) -> Result<url::Url, String> {    if u.len() > 32 * 1024 {        return Err("a URL is at most 32 KB here".into());    }    url::Url::parse(u.trim()).map_err(|e| format!("`{}` is not a URL: {}", u, e))}/// A URL's parts: `{ scheme, username, host, port, path, query, fragment, origin, href }`, `query` a map of its parameters (the last value of a repeated one), `port` null when it is the scheme's default.pub fn url_parse(url: &str) -> Result<Value, String> {    let u = parse(url)?;    let q: Map<String, Value> = u.query_pairs().map(|(k, v)| (k.into_owned(), json!(v))).collect();    Ok(json!({        "scheme": u.scheme(), "username": u.username(), "host": u.host_str(), "port": u.port(), "path": u.path(),        "query": q, "fragment": u.fragment(), "origin": u.origin().ascii_serialization(), "href": u.as_str(),    }))}/// A link resolved against the page it is on: `url_join("https://a.com/blog/post", "../img/x.png")` is `https://a.com/img/x.png`. An absolute `relative` comes back as it is.pub fn url_join(base: &str, relative: &str) -> Result<String, String> {    parse(base)?.join(relative.trim()).map(|u| u.to_string()).map_err(|e| format!("cannot join `{}`: {}", relative, e))}fn is_tracking(k: &str) -> bool {    let k = k.to_ascii_lowercase();    k.starts_with("utm_") || TRACKING.contains(&k.as_str())}/// The URL without tracking parameters: every `utm_*`, and fbclid, gclid, msclkid, mc_eid, _ga, igshid and others ad networks add. The rest is left in its order.pub fn url_strip_tracking(url: &str) -> Result<String, String> {    let mut u = parse(url)?;    let kept: Vec<(String, String)> = u.query_pairs().filter(|(k, _)| !is_tracking(k)).map(|(k, v)| (k.into_owned(), v.into_owned())).collect();    if kept.is_empty() {        u.set_query(None);    } else {        u.query_pairs_mut().clear().extend_pairs(kept);    }    Ok(u.to_string())}/// One form for URLs that mean the same page, for comparing, deduplicating and cache keys: scheme and host in lower case (as parsing does),/// default port dropped, `.` and `..` resolved, tracking parameters dropped, the rest sorted by name, the fragment dropped, and a lone `/` path kept.pub fn url_normalize(url: &str) -> Result<String, String> {    let mut u = parse(url)?;    u.set_fragment(None);    let mut kept: Vec<(String, String)> = u.query_pairs().filter(|(k, _)| !is_tracking(k)).map(|(k, v)| (k.into_owned(), v.into_owned())).collect();    kept.sort();    if kept.is_empty() {        u.set_query(None);    } else {        u.query_pairs_mut().clear().extend_pairs(kept);    }    if let Some(h) = u.host_str().map(|h| h.trim_end_matches('.').to_string()) {        let _ = u.set_host(Some(&h));    }    Ok(u.to_string())}/// The URL with query parameters set from `params` (`{ page: 2, sort: "new" }`): existing ones replaced in place, new ones added, a null value removing one.pub fn url_with_query(url: &str, params: std::collections::HashMap<String, serde_json::Value>) -> Result<String, String> {    let mut u = parse(url)?;    let set: BTreeMap<String, Option<String>> = params        .into_iter()        .map(|(k, v)| (k, match v {            Value::Null => None,            Value::String(s) => Some(s),            other => Some(other.to_string()),        }))        .collect();    let mut out: Vec<(String, String)> = Vec::new();    let mut done = std::collections::HashSet::new();    for (k, v) in u.query_pairs().map(|(k, v)| (k.into_owned(), v.into_owned())).collect::<Vec<_>>() {        match set.get(&k) {            Some(Some(nv)) if done.insert(k.clone()) => out.push((k, nv.clone())),            Some(_) => {}            None => out.push((k, v)),        }    }    for (k, v) in &set {        if let Some(v) = v {            if !done.contains(k) {                out.push((k.clone(), v.clone()));            }        }    }    if out.is_empty() {        u.set_query(None);    } else {        u.query_pairs_mut().clear().extend_pairs(out);    }    Ok(u.to_string())}
Thread
76Useful? Like it, or add to its thread below.

Thread 0 posts

On URL tools: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.