GitHub
01A URL library for MoonBit spec-first

moonurl.

URLs read the way a browser reads them and the way RFC 3986 says to read them: one type, two grammars. Host names are internationalised, URI templates fill in and read back, and form bodies ride on the same percent coding. No sockets.

2
grammars · RFC 3986 & WHATWG
6
specifications implemented
4
URI Template levels
UTF‑8
/ NFC normalisation
≡

Two grammars, one type

Switch the reader for a single string and keep working with the result.

@

Internationalised names

UTS 46 mapping and RFC 3492 punycode turn any script into ASCII.

{ }

URI templates

RFC 6570, all four levels: expand a URI and read one back.

&

Form encoding

application/x-www-form-urlencoded as ordered, repeatable name/value pairs.

◧

Data URLs

RFC 2397 — a whole representation written inside the reference to it.

%

Percent coding

Escape and unescape by component, with the safe set each one allows.

Add the package
moon add moonbitstack/moonurl
Apache-2.0 · MoonBit Scroll down ↓ · or pick a section on the right
02Guide

From first line to the corners

Each snippet is a test in a project that has only this package added: every assertion states what the library returns.

1

Parse and read a reference

A reference is five components. Read it, then ask for any of them.

Decode & read
let url = @moonurl.Url::decode("https://leo:pw@example.test:8443/a/b?x=1#top")
assert_eq(url.scheme, Some("https"))
assert_eq(url.host, Some("example.test"))
assert_eq(url.port, Some(8443))
assert_eq(url.query, Some("x=1"))
assert_eq(url.origin(), "https://example.test:8443")
2

Resolve a relative reference

resolve applies RFC 3986 §5.4 against a base.

Resolve
let base = @moonurl.Url::decode("http://example.test/a/b/c")
assert_eq(base.resolve(@moonurl.Url::decode("../d")).encode(), "http://example.test/a/d")
3

Two readings of the same string

syntax defaults to rfc3986, so existing code is unchanged. The WHATWG reading normalises on the way in.

Two grammars
let generic = @moonurl.Url::decode("HTTP://EXAMPLE.test:80/a/../b")
assert_eq(generic.encode(), "HTTP://EXAMPLE.test:80/a/../b")

let browser = @moonurl.Url::decode("HTTP://EXAMPLE.test:80/a/../b", syntax=@moonurl.Whatwg)
assert_eq(browser.encode(), "http://example.test/b")
4

Names in any script

A host name goes through UTS 46; an ASCII name is taken as it stands.

Internationalised host
assert_eq(@moonurl.domain("MÜNCHEN.example"), "xn--mnchen-3ya.example")

let numeric = @moonurl.Url::decode("http://192.0x00A80001/", syntax=@moonurl.Whatwg)
assert_eq(numeric.host, Some("192.168.0.1"))
5

Fill in a template, read it back

All four levels; capture reverses expand.

Templates
let values : Map[String, @template.Value] = Map([])
values.set("id", @template.Value::Text("42"))
values.set("page", @template.Value::Text("2"))

let users = @template.Template::decode("/users/{id}{?page}")
assert_eq(users.expand(values), "/users/42?page=2")
assert_eq(users.capture("/users/42?page=2"), Some([("id", "42"), ("page", "2")]))
6

Form bodies and data URLs

A URL's query and a form body are the same ordered pairs. Sorting them by name is what makes two bodies with the same pairs sign the same way.

Forms & data URLs
let q = @moonurl.Query::decode(b"a=1&b=2&a=3")
assert_eq(q.get("a"), Some("1"))
assert_eq(q.values("a"), ["1", "3"])
assert_eq(q.encode(), "a=1&b=2&a=3")

let sorted = @moonurl.Query::decode(b"b=2&a=1&b=3")
sorted.sort()
assert_eq(sorted.encode(), "a=1&b=2&b=3")

let greeting = @dataurl.Url::decode("data:text/plain,Hi%20there")
assert_eq(greeting.charset(), "US-ASCII")
Run the examples: moon run src/examples/tour Source on GitHub ↗
03Reference

API

Public items, grouped by package. Signatures and descriptions are read out of the sources, so the list below is the interface the compiler publishes.

119 public items in 4 packages: everything the interface files publish. The equal, not_equal and to_repr that Eq and Debug bring are named at the foot of the type that carries them.

moonurl Url · Query · percent coding

Parsing, recomposing, resolving, normalising, origins, setters; percent coding by component; form bodies.

Types

+enum Kindenum

Which component an octet is going into, and so which octets may stand for themselves (RFC 3986 §2.2, §3).

& is ordinary inside a path and a separator inside a query. The grammar answers that, not the caller.

Bare

Unreserved only — everything else is escaped. This is what one piece of a query goes out as, since & and = inside a value must not read as the separators they look like.

Host

reg-name (§3.2.2): unreserved and sub-delims.

Userinfo

userinfo (§3.2.1): reg-name and :.

Segment

One segment (§3.3): pchar, so / is escaped and stays one segment.

Path

A whole path (§3.3): pchar and /.

Query

query (§3.4): pchar, / and ?.

Fragment

fragment (§3.5): the same set as query.

Whole

A whole URI reference: everything the grammar allows anywhere, % included. What a Location header carries — the value is already a URI, and only the octets no URI may contain need escaping.

Derived: equal, not_equal, to_repr.

+struct Limitsstruct

What a decode accepts. Past either bound the whole input is refused rather than cut short, so a handler never acts on part of a form.

pairsInt32

Most pairs.

sizeInt32

Most bytes in one pair as it arrives, still encoded.

Derived: equal, not_equal, to_repr.

+fn Limits::new(pairs~ : Int32 = limits.pairs, size~ : Int32 = limits.size) -> Limits

Limits by name, each defaulting to the preset's.

+struct Querystruct

The name–value pairs of an application/x-www-form-urlencoded string: a URL's query, or a form body (WHATWG URL Standard §5). Order and repeated names are kept, as URLSearchParams and Go's url.Values keep them.

Derived: equal, not_equal, to_repr.

+fn Query::add(self : Query, name : String, value : String) -> Unit

Add a pair at the end, keeping any already under name.

+fn Query::decode(raw : BytesView, limits~ : Limits = limits) -> Query raise Refused

Read an application/x-www-form-urlencoded string (WHATWG URL Standard §5.1).

& separates pairs and the first = separates a name from its value; + is a space here and nowhere else in a URL. Escapes are undone to octets and the octets read as UTF-8, a broken sequence becoming U+FFFD. An empty piece between two & is no pair at all.

test {
  let form = @moonurl.Query::decode(b"q=moon+bit&tag=a&tag=b")
  assert_eq(form.get("q"), Some("moon bit"))
  assert_eq(form.values("tag"), ["a", "b"])
  assert_eq(form.encode(), "q=moon+bit&tag=a&tag=b")
}
+fn Query::encode(self : Query) -> String

Write the pairs as application/x-www-form-urlencoded (WHATWG URL Standard §5.2): letters, digits and *-._ stand for themselves, a space is +, and every other octet of the UTF-8 is %XX.

+fn Query::entries(self : Query) -> Array[(String, String)]

Every pair, in order.

+fn Query::get(self : Query, name : StringView) -> String?

The first value under name.

+fn Query::has(self : Query, name : StringView) -> Bool

Whether any pair is named name.

+fn Query::new(pairs? : Array[(String, String)]) -> Query

A query holding pairs, in order.

+fn Query::remove(self : Query, name : StringView) -> Unit

Remove every pair under name.

+fn Query::set(self : Query, name : String, value : String) -> Unit

Leave exactly one pair under name, holding value: the first one's place if there was one, the end if not.

+fn Query::sort(self : Query) -> Unit

Put the pairs in order by name, keeping pairs that share a name in the order they arrived (WHATWG URL Standard §5.1, URLSearchParams.sort).

The order is that of the UTF-16 code units of the names, which is what a browser sorts by, so \u{1F308} comes before \u{FB03}: its high surrogate is the smaller unit even though the code point is the larger. Query::encode keeps whatever order it is given, so this is how two bodies with the same pairs come out byte for byte alike. It does not touch the pairs' values.

test {
  let body = @moonurl.Query::decode(b"b=2&a=1&b=3")
  body.sort()
  assert_eq(body.encode(), "a=1&b=2&b=3")
}
+fn Query::values(self : Query, name : StringView) -> Array[String]

Every value under name, in order.

+suberror Refusederror

Why a string is not a URI reference. Only the authority can fail: §B splits any string at all into the five top-level components, and does not split that one.

Portat~ : Int32

A port that is not digits, at this offset.

Hostat~ : Int32

An IP-literal with no closing bracket, at the opening one's offset.

Nameat~ : Int32

A host that is not a host: an empty one where one is needed, a code point the grammar forbids in one, or a name the IDNA rules refuse.

Addressat~ : Int32

A number that reads as an IP address and is not one.

Schemeat~ : Int32

Input with no scheme, and no base to take one from.

Pairslimit~ : Int32

A form-encoded string with more pairs than the limit.

Pairlimit~ : Int32, got~ : Int32

One form-encoded pair longer than the limit, in octets.

Derived: equal, not_equal, to_repr.

+enum Syntaxenum

Which grammar decides what a string means. The two disagree about almost everything a browser has to get right, and both are in the standard library of every language that talks to the web.

Rfc3986

RFC 3986: the generic grammar, where http://h:80/ keeps its port and http:g is a URI with scheme http.

Whatwg

The WHATWG URL Standard: special schemes with their default ports, backslashes as soliduses, IPv4 in any of its spellings, and a host name run through IDNA.

Derived: equal, not_equal, to_repr.

+struct Urlstruct

A URI reference taken apart (RFC 3986 §3).

No scheme makes it relative (§4.2). No host means no authority, which is not an empty host — file:///tmp has one, mailto:x@y has none.

Fields stay percent-encoded. Decoding on the way in would lose the difference between a / that separates two segments and a %2F inside one.

schemeString?

The scheme with no colon, https rather than https:.

userinfoString?

user:password as the authority spelled it, both still escaped.

hostString?

A name in ASCII, an IPv4 address in dotted decimal, or an IPv6 address without its brackets.

portInt32?

The port as a number. Nothing means the authority wrote none, which is not the same as the scheme having a default.

pathString

The path, always a string: a reference with an authority and nothing after it has the empty path, not no path.

queryString?

What follows the ?, still escaped. Nothing means no query at all, which a bare ? distinguishes from an empty one.

fragmentString?

What follows the #, still escaped.

Derived: equal, not_equal, to_repr.

+fn Url::authority(self : Url) -> String?

The authority as one string, brackets restored around an IP-literal (§3.2).

+fn Url::decode(text : StringView, syntax~ : Syntax = rfc3986, base? : Url) -> Url raise Refused

Take a reference apart.

Under Rfc3986 any string splits (§3), and only the authority can be malformed. Under Whatwg the string is what a browser would make of it: a reference that does not parse is refused rather than guessed at.

base resolves a relative reference against one, in whichever grammar is asked for. The WHATWG algorithm takes the base as text and parses it again, so a base that is itself not a URL is refused here too.

test {
  // RFC 3986 keeps the reference as it was written.
  let url = @moonurl.Url::decode("HTTP://EXAMPLE.test:80/a/../b?q#f")
  assert_eq(url.encode(), "HTTP://EXAMPLE.test:80/a/../b?q#f")
  let whatwg = @moonurl.Url::decode(
    "HTTP://EXAMPLE.test:80/a/../b",
    syntax=@moonurl.Whatwg,
  )
  assert_eq(whatwg.encode(), "http://example.test/b")
}
+fn Url::encode(self : Url) -> String

Put the reference back together (RFC 3986 §5.3).

+fn Url::is_absolute(self : Url) -> Bool

A reference with a scheme and no fragment is a URI (§3); anything else is a relative reference or a same-document one.

+fn Url::new(scheme? : String, userinfo? : String, host? : String, port? : Int32, path~ : String = "", query? : String, fragment? : String) -> Url

A reference this shape, for building one rather than reading one.

+fn Url::normalize(self : Url) -> Url

Syntax-based normalisation (RFC 3986 §6.2.2): scheme and host to lower case, escapes normalised, dot segments out.

Not §6.2.3: dropping :80 is true of http and false in general, and this package holds no register of schemes.

+fn Url::origin(self : Url) -> String

The origin of a URL (§4.7): what two URLs have to share to be the same site, written scheme://host:port.

A blob: URL carries the origin of the URL inside it. Everything else that is not a scheme with an authority of its own — file:, data:, mailto: — has an origin no other URL shares, which is written "null".

test {
  let url = @moonurl.Url::decode(
    "https://example.test/a",
    syntax=@moonurl.Whatwg,
  )
  assert_eq(url.origin(), "https://example.test")
  let file = @moonurl.Url::decode("file:///tmp/x", syntax=@moonurl.Whatwg)
  assert_eq(file.origin(), "null")
}
+fn Url::params(self : Url) -> Query raise Refused

The query as pairs, read the way URLSearchParams reads it (WHATWG URL Standard §5.1).

test {
  let url = @moonurl.Url::decode(
    "https://h/?a=1&a=2&b=x+y",
    syntax=@moonurl.Whatwg,
  )
  assert_eq(url.params().values("a").length(), 2)
  assert_eq(url.params().get("b").unwrap(), "x y")
}
+fn Url::resolve(self : Url, reference : Url) -> Url

Resolve reference against this base (RFC 3986 §5.2.2, strict).

Strict: a reference that repeats the base's scheme stays absolute. The non-strict reading is for pre-1998 parsers and turns http:g into something it does not say.

test {
  let base = @moonurl.Url::decode("http://example.test/a/b/c")
  assert_eq(
    base.resolve(@moonurl.Url::decode("../d")).encode(),
    "http://example.test/a/d",
  )
  assert_eq(
    base.resolve(@moonurl.Url::decode("//other.test/x")).encode(),
    "http://other.test/x",
  )
}
+fn Url::set_fragment(self : Url, value : StringView) -> Url

This URL with its fragment replaced, the leading # of the value optional. An empty value takes the fragment away.

+fn Url::set_host(self : Url, value : StringView) -> Url

This URL with its host replaced, and its port too if the value carries one.

An empty value is a host with nothing in it, not a missing host: the URL keeps its shape and loses its authority's middle.

+fn Url::set_hostname(self : Url, value : StringView) -> Url

This URL with its host replaced and its port left alone.

+fn Url::set_password(self : Url, value : StringView) -> Url

This URL with its password replaced.

+fn Url::set_path(self : Url, value : StringView) -> Url

This URL with its path replaced. A URL whose path is opaque — mailto:, data: — has no path to replace, and keeps the one it has.

+fn Url::set_port(self : Url, value : StringView) -> Url

This URL with its port replaced. An empty value takes the port away, which is not the same as setting it to zero.

+fn Url::set_query(self : Url, value : StringView) -> Url

This URL with its query replaced, the leading ? of the value optional. An empty value takes the query away.

+fn Url::set_scheme(self : Url, value : StringView) -> Url

This URL with its scheme replaced, or this URL if the value is not a scheme.

A URL may change between special and non-special schemes only through another special one, and only a URL with no credentials, port or file host can become file; the setter leaves anything else as it was, as the standard's own setter does.

test {
  let url = @moonurl.Url::decode(
    "http://example.test/a",
    syntax=@moonurl.Whatwg,
  )
  assert_eq(url.set_scheme("https").encode(), "https://example.test/a")
  assert_eq(url.set_scheme("1bad").encode(), "http://example.test/a")
}
+fn Url::set_username(self : Url, value : StringView) -> Url

This URL with its username replaced.

Functions

+fn domain(text : StringView, strict~ : Bool = false) -> String raise Refused

The domain parser of §3.3: the host name a browser would make of text.

Strict is for a name that has to be one: CheckHyphens, the STD 3 rules and the DNS lengths are all on, and invalid Punycode is refused.

Lenient is the browser: an ASCII string is taken as it stands, lowercased, whatever IDNA would say about the label — a name that reads as Punycode but is not still addresses something. Everything else goes through UTS 46.

test {
  assert_eq(@moonurl.domain("MÜNCHEN.example"), "xn--mnchen-3ya.example")
  assert_eq(@moonurl.domain("xn--a"), "xn--a")
}
+fn escape(raw : BytesView, kind~ : Kind = kind, spare~ : StringView = ""[:]) -> String

Percent-encode octets for kind (RFC 3986 §2.1).

Octets, not text: what travels inside a URI is whatever the caller encoded it as. escape_text is the one that assumes UTF-8.

spare is for percent coding that is not going into a URI at all — gRPC's grpc-message keeps all of printable ASCII but %, which is no component of RFC 3986's. A URI component names its kind and leaves spare alone.

+fn escape_text(text : StringView, kind~ : Kind = kind, spare~ : StringView = ""[:]) -> String

Percent-encode text as UTF-8 (RFC 3986 §2.5).

test {
  assert_eq(@moonurl.escape_text("a b/c", kind=@moonurl.Path), "a%20b/c")
  assert_eq(@moonurl.escape_text("a b/c", kind=@moonurl.Segment), "a%20b%2Fc")
}
+fn escaped(text : StringView, kind~ : Kind = kind, spare~ : StringView = ""[:]) -> Bool

Whether every % is a complete escape and nothing outside kind's set is there unescaped — the check unescape does not make.

+fn normalize_escapes(text : StringView) -> String

Percent-encoding normalised (RFC 3986 §6.2.2.2): hex digits in upper case, and escapes of unreserved octets written out plainly.

+fn remove_dots(path : String) -> String

Take out the . and .. segments (RFC 3986 §5.2.4).

The section's loop rather than a split-and-rejoin: the two differ at exactly the inputs that matter — a trailing .., a path that climbs above its own root, /..//.

+fn unescape(raw : BytesView) -> Bytes

Undo percent-encoding (RFC 3986 §2.1).

A % without two hex digits after it stands for itself. This runs on request targets and form bodies, where refusing the whole message over one stray % helps nobody; escaped is for the caller who would rather be told.

+fn unescape_text(text : StringView) -> String

Undo percent-encoding and read the result as UTF-8, replacing what is not.

Values

+let kind : Kind

The strictest component, for when nothing says. Escaping an octet that did not need it changes nothing; leaving one that did changes where the component ends.

+let limits : Limits

A thousand pairs of up to 1 MiB each: Starlette's max_fields and max_part_size, and the parameterLimit of Node's qs.

+let rfc3986 : Syntax

The grammar Url::decode uses when nothing says.

moonurl/idna punycode · UTS 46

A name mapped to ASCII and back: the mapping table, NFC, hyphen and joiner rules, the bidi rule, and DNS lengths.

Types

+enum Faultenum

Why a name is not the name the standard would accept.

Shapeat~ : Int32

A label that is not in NFC, begins with a combining mark, or begins with xn-- where that prefix is not allowed (V1, V4, V6).

Hyphenat~ : Int32

A hyphen where check_hyphens forbids one (V2, V3).

Dotat~ : Int32

A full stop inside a label (V5).

Disallowedat~ : Int32, got~ : Char

A code point the mapping table does not allow here (V7).

Std3at~ : Int32, got~ : Char

ASCII outside a to z, 0 to 9 and -, while std3 is on (U1).

Punycodeat~ : Int32

An xn-- label that does not decode, decodes to nothing, or decodes to ASCII alone (P4).

Joinerat~ : Int32

A joiner out of context (C1, C2).

Bidiat~ : Int32

A label the bidi rule refuses (B1 to B6).

Emptyat~ : Int32

An empty label (A4_2, X4_2).

Lengthlimit~ : Int32, got~ : Int32

A name or label past its length limit (A4_1, A4_2).

Derived: equal, not_equal, to_repr.

+struct Optionsstruct

What UTS 46 leaves to the caller. Its two presets cover the two callers there are: a browser reading a host name out of a link, and a zone file.

transitionalBool

Treat ß, ς and the two joiners the way IDNA2003 did. Deprecated by the standard, and kept because some names still exist in that spelling.

check_hyphensBool

Refuse a label with a hyphen where one may not be: leading, trailing, or in the third and fourth positions of a label that is not an A-label.

check_bidiBool

Refuse a name whose right-to-left labels break the bidi rule of RFC 5893.

check_joinersBool

Refuse a zero width joiner or non-joiner outside the context that allows one (RFC 5892 Appendix A).

std3Bool

Refuse ASCII outside a to z, 0 to 9 and -, the STD 3 rules.

dns_lengthBool

Refuse a name past the DNS limits: 253 octets, 63 to a label.

ignore_invalid_punycodeBool

Pass an xn-- label that is not Punycode through untouched instead of refusing it. The URL standard asks for this: a browser keeps a label it cannot read rather than dropping the host.

Derived: equal, not_equal, to_repr.

+struct Outcomestruct

A name as far as the standard could convert it, and what it objected to on the way. Conversion does not stop at the first error, so a caller that wants to show the user what is wrong has the rest of the name too.

nameString

The converted name.

errorsArray[Fault]

Everything wrong with it, in no particular order.

Derived: equal, not_equal, to_repr.

+fn Outcome::is_ok(self : Outcome) -> Bool

Whether the name is one the standard accepts.

+suberror Refusederror

Why a label is not Punycode.

Digitat~ : Int32

A character that is not a Punycode digit, at this offset.

Overflowat~ : Int32

A number that runs past the code space, at this offset.

Derived: equal, not_equal, to_repr.

Functions

+fn decode(text : StringView) -> String raise Refused

Decode one label (RFC 3492 §6.2).

Both cases of digit are accepted: a name may have been upper-cased on its way here, and case carries nothing in Punycode.

+fn encode(text : StringView) -> String

Encode one label (RFC 3492 §6.3).

The label is not split and no xn-- is written: that prefix belongs to UTS 46, which decides when a label needs encoding at all.

test {
  assert_eq(@idna.encode("münchen"), "mnchen-3ya")
  assert_eq(@idna.decode("mnchen-3ya"), "münchen")
}
+fn to_ascii(name : StringView, options~ : Options = options) -> Outcome

A name as ASCII, the form that goes on the wire (UTS 46 §4.2).

test {
  let converted = @idna.to_ascii("münchen.example")
  assert_eq(converted.name, "xn--mnchen-3ya.example")
  assert_eq(converted.is_ok(), true)
}
+fn to_unicode(name : StringView, options~ : Options = options) -> Outcome

A name as Unicode, the form that is read (UTS 46 §4).

Values

+let options : Options

What a host name gets: no STD 3, no hyphen rule (an A-label always has a hyphen in the fourth position), the bidi and joiner rules on, and no DNS length check, which the resolver is better placed to make.

+let strict : Options

What a zone file gets: everything options turns off, turned on.

moonurl/template RFC 6570

Holes named and filled: simple and reserved expansions, label, path and parameter operators, prefixes and exploded lists and pairs.

Types

+suberror Refusederror

Why a template cannot be read, or a value cannot be filled in.

Bracketat~ : Int32

A { that is never closed, or a } that opens nothing, at this offset.

Syntaxat~ : Int32, got~ : Char

A character that may not stand in a template where this one does.

Operatorat~ : Int32, got~ : Char

A reserved operator: =, ,, !, @ or | (RFC 6570 §2.2).

Lengthat~ : Int32, limit~ : Int32, got~ : Int32

A :N prefix that is not one to four digits from 1 to 9999.

Compositename~ : String

A :N prefix on a variable bound to a list or a map, where the standard says the modifier does not apply (RFC 6570 §2.4.1).

Derived: equal, not_equal, to_repr.

+struct Templatestruct

A template (RFC 6570 §2), kept as the pieces it was read into.

Derived: equal, not_equal, to_repr.

+fn Template::capture(self : Template, uri : StringView) -> Array[(String, String)]?

The variables a URI spells out, in the order the template names them, or nothing if the URI is not one this template could have produced.

A template is a pattern, not a grammar: a value that holds the separator is read as two values. Escapes are undone, so a name comes back as it was.

test {
  let template = @template.Template::decode("/users/{id}{?page}")
  assert_eq(template.capture("/users/42?page=2").unwrap(), [
    ("id", "42"),
    ("page", "2"),
  ])
}
+fn Template::decode(text : StringView) -> Template raise Refused

Read a template (§2).

test {
  let template = @template.Template::decode("http://h{/list*}{?q,page}")
  assert_eq(template.encode(), "http://h{/list*}{?q,page}")
}
+fn Template::encode(self : Template) -> String

The template as it was written, the way §2 spells it.

+fn[T : Variables] Template::expand(self : Template, variables : T) -> String raise Refused

Fill the template in (§3.2).

test {
  let values : Map[String, @template.Value] = Map([])
  values.set("who", @template.Value::Text("moon"))
  values.set("list", @template.Value::List(["a", "b"]))
  let template = @template.Template::decode("{/who}{?list}")
  assert_eq(template.expand(values), "/moon?list=a,b")
}
+enum Valueenum

What a variable holds (RFC 6570 §2.3): one string, a list of them, or a set of named ones.

TextString

A string.

ListArray[String]

A list, whose items are expanded in order.

PairsArray[(String, String)]

An associative array: names with values, expanded in order.

Derived: equal, not_equal, to_repr.

Traits

+trait Variablestrait
trait Variables {
  fn get(Self, StringView) -> Value?
}

Where the values of a template come from. Implement it for whatever holds them — a map, a configuration record, a request's query.

test {
  let values : Map[String, @template.Value] = Map([])
  values.set("q", @template.Value::Text("moon bit"))
  assert_eq(
    @template.Template::decode("?q={q}").expand(values),
    "?q=moon%20bit",
  )
}
+impl Variables for Map[String, Value]impl

A map of names to values is the obvious place to look.

moonurl/dataurl RFC 2397

A whole representation written inside the reference to it.

Types

+suberror Refusederror

Why a string is not a data URL.

Scheme

It does not begin with data:.

Comma

Nothing separates the header from the payload (§3 requires a comma).

Escapeat~ : Int32

A % with fewer than two hex digits after it. Only under strict.

Base64at~ : Int32

The payload is not base64, at this offset into it.

Derived: equal, not_equal, to_repr.

+struct Urlstruct

A data URL taken apart: what the octets are, and the octets (RFC 2397 §3).

data is already decoded. Which of the two spellings the URL used is a property of the text, not of what it carries.

kindString

The media type with its parameters, as the URL spells it.

dataBytes

The payload.

Derived: equal, not_equal, to_repr.

+fn Url::charset(self : Url) -> String

The charset the payload's text is in (RFC 2397 §2).

US-ASCII when nothing says otherwise, which is the default media type's own parameter rather than a guess of ours.

+fn Url::decode(text : StringView, strict~ : Bool = false) -> Url raise Refused

Read a data URL.

strict turns the two things browsers let pass into errors: base64 with whitespace or the wrong padding, and a % that is not an escape. Left off, a lone % stands for itself and whitespace is dropped.

test {
  let greeting = @dataurl.Url::decode("data:text/plain,Hi%20there")
  assert_eq(greeting.kind, "text/plain")
  assert_eq(greeting.data, b"Hi there")
  assert_eq(greeting.encode(base64=true), "data:text/plain;base64,SGkgdGhlcmU=")
}
+fn Url::encode(self : Url, base64~ : Bool = true) -> String

Write the URL out.

base64 defaults on, as webpack's asset modules do (generator.dataUrl.encoding). Turn it off for SVG and anything else a human may want to read in the URL.

A media type equal to kind is left out; reading one back puts it there again.

+fn Url::new(data : Bytes, kind~ : String = kind) -> Url

A data URL carrying these octets.

Values

+let kind : String

What a data URL means when it names no media type (RFC 2397 §2).

Errors are one enum per package — Refused — with an offset named at, or the variable's name where a prefix is one a list or a map cannot take. Source on GitHub ↗
04Showcase Live & hands-on

See moonurl think

The same input, read several ways — then the tools to try your own.

⇄

Two grammars

One decoder, two standards. WHATWG normalises on the way in; RFC 3986 leaves the dots and the case alone.

RFC 3986http://EXAMPLE.com/A/../b
WHATWGhttp://example.com/b
@

Any script

UTS 46 maps and normalises; RFC 3492 punycode shrinks it to ASCII that DNS understands.

UnicodeMÜNCHEN.example
ASCIIxn--mnchen-3ya.example
{ }

Templates

Fill a hole, then read the names back out of the URI it produced.

Template/users/{id}{?page}
Filled/users/42?page=2

Try it yourself

Internationalised name

Type a host name. Non-ASCII labels become xn-- under UTS 46.

URI Template

Give a template and variables as JSON. Lists and objects become lists and pairs.

Take a URL apart

The same string by either grammar: RFC 3986, or the WHATWG URL Standard a browser follows.

The demos run moonurl itself, built to JavaScript; nothing leaves your browser. Source on GitHub ↗