///|
/// Percent-encode the characters that would break markdown link/image syntax
/// (port of `converter.percentEncodingReplacer`).
fn percent_encode_url_chars(url : String) -> String {
let out = StringBuilder(size_hint=url.length())
for ch in url {
match ch {
' ' => out.write_string("%20")
'[' => out.write_string("%5B")
']' => out.write_string("%5D")
'(' => out.write_string("%28")
')' => out.write_string("%29")
'<' => out.write_string("%3C")
'>' => out.write_string("%3E")
_ => out.write_char(ch)
}
}
out.to_string()
}
///|
/// Assemble an absolute URL, mirroring `converter.defaultAssembleAbsoluteURL`
/// closely enough for the common cases:
/// - "#" is returned unchanged
/// - newlines/tabs are pre-encoded
/// - "data:" URIs are only percent-encoded
/// - a relative URL is joined onto `domain` when one is provided
/// - query components are normalized with RFC 3986 percent-encoding
/// - special characters are percent-encoded
fn assemble_absolute_url(
tag_name : String,
raw_url : String,
domain : String,
) -> String {
ignore(tag_name)
let raw = @textutils.trim_space(raw_url)
if raw is "#" {
return raw
}
// Increase the chance the url stays usable.
let raw = raw
.replace_all(old="\n", new="%0A")
.replace_all(old="\t", new="%09")
let scheme = url_scheme(raw)
if scheme is Some("data") {
return percent_encode_url_chars(raw)
}
let resolved = if domain != "" && is_relative_url(raw, scheme) {
join_url(domain, raw)
} else {
raw
}
percent_encode_url_chars(normalize_query_component(resolved))
}
///|
/// Normalize the query component of a URI reference according to RFC 3986:
/// query = *( pchar / "/" / "?" ), where pchar allows unreserved,
/// pct-encoded, sub-delims, ":" and "@".
fn normalize_query_component(url : String) -> String {
let (before_fragment, fragment) = split_fragment_suffix(url)
match find_char(before_fragment, '?') {
Some(query_start) => {
let before_query = before_fragment[0:query_start + 1].to_owned()
let query = before_fragment[query_start + 1:].to_owned()
before_query + percent_encode_query(query) + fragment
}
None => url
}
}
///|
fn split_fragment_suffix(s : String) -> (String, String) {
match find_char(s, '#') {
Some(idx) => (s[0:idx].to_owned(), s[idx:].to_owned())
None => (s, "")
}
}
///|
fn percent_encode_query(query : String) -> String {
let out = StringBuilder(size_hint=query.length())
let mut i = 0
while i < query.length() {
match query.get_char(i) {
Some('%') if has_pct_encoded_triplet(query, i) => {
out.write_char('%')
out.write_char(uppercase_hex_char(query.get_char(i + 1).unwrap()))
out.write_char(uppercase_hex_char(query.get_char(i + 2).unwrap()))
i += 3
}
Some(ch) if is_rfc3986_query_char(ch) => {
out.write_char(ch)
i += ch.to_string().length()
}
Some(ch) => {
write_percent_encoded_utf8(out, ch)
i += ch.to_string().length()
}
None => i += 1
}
}
out.to_string()
}
///|
fn has_pct_encoded_triplet(s : String, idx : Int) -> Bool {
match (s.get_char(idx + 1), s.get_char(idx + 2)) {
(Some(a), Some(b)) => is_ascii_hex_digit(a) && is_ascii_hex_digit(b)
_ => false
}
}
///|
fn is_rfc3986_query_char(ch : Char) -> Bool {
is_ascii_alpha(ch) ||
is_ascii_digit(ch) ||
ch is ('-' | '.' | '_' | '~') ||
ch is ('!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=') ||
ch is (':' | '@' | '/' | '?')
}
///|
fn is_ascii_hex_digit(ch : Char) -> Bool {
is_ascii_digit(ch) || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')
}
///|
fn uppercase_hex_char(ch : Char) -> Char {
match ch {
'a' => 'A'
'b' => 'B'
'c' => 'C'
'd' => 'D'
'e' => 'E'
'f' => 'F'
_ => ch
}
}
///|
fn write_percent_encoded_utf8(out : StringBuilder, ch : Char) -> Unit {
for byte in @utf8.encode(ch.to_string()) {
write_percent_encoded_byte(out, byte.to_int())
}
}
///|
fn write_percent_encoded_byte(out : StringBuilder, byte : Int) -> Unit {
out.write_char('%')
out.write_char(hex_digit(byte / 16))
out.write_char(hex_digit(byte % 16))
}
///|
fn hex_digit(n : Int) -> Char {
if n < 10 {
('0'.to_int() + n).unsafe_to_char()
} else {
('A'.to_int() + n - 10).unsafe_to_char()
}
}
///|
/// Extract the URL scheme (lowercased) if the string starts with
/// `scheme:` where scheme is `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`.
fn url_scheme(url : String) -> String? {
let mut i = 0
let len = url.length()
guard len > 0 else { return None }
// First char must be a letter.
match url.get_char(0) {
Some(ch) if is_ascii_alpha(ch) => i = 1
_ => return None
}
while i < len {
match url.get_char(i) {
Some(ch) =>
if is_ascii_alpha(ch) || is_ascii_digit(ch) || ch is ('+' | '-' | '.') {
i += 1
} else if ch is ':' {
return Some(url[0:i].to_owned().to_lower())
} else {
return None
}
None => return None
}
}
None
}
///|
fn is_ascii_alpha(ch : Char) -> Bool {
(ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
}
///|
fn is_ascii_digit(ch : Char) -> Bool {
ch >= '0' && ch <= '9'
}
///|
/// A URL is relative (joinable onto a domain) when it has no scheme and is
/// not a protocol-relative (`//host`) or fragment-only (`#x`) URL.
fn is_relative_url(url : String, scheme : String?) -> Bool {
if scheme is Some(_) {
return false
}
if url.has_prefix("//") {
return false
}
true
}
///|
/// Join a relative URL onto a base domain (which may or may not carry a
/// scheme). Mirrors RFC 3986 relative reference resolution for path, query,
/// and fragment handling.
fn join_url(domain : String, raw : String) -> String {
let (scheme_prefix, host_and_path) = split_scheme(domain)
let scheme_prefix = if scheme_prefix is "" {
"http://"
} else {
scheme_prefix
}
let (host, base_ref) = split_authority_ref(host_and_path)
let base_without_fragment = strip_fragment(base_ref)
let base_path = strip_query(base_without_fragment)
if raw is "" {
return scheme_prefix + host + base_without_fragment
}
if raw.has_prefix("#") {
return scheme_prefix + host + base_without_fragment + raw
}
if raw.has_prefix("?") {
return scheme_prefix + host + base_path + raw
}
let (raw_path, raw_suffix) = split_path_suffix(raw)
if raw.has_prefix("/") {
scheme_prefix + host + remove_dot_segments(raw_path) + raw_suffix
} else {
let merged = merge_relative_path(base_path, raw_path)
scheme_prefix + host + remove_dot_segments(merged) + raw_suffix
}
}
///|
/// Separate authority from the path/query/fragment reference.
fn split_authority_ref(s : String) -> (String, String) {
for i, ch in s {
if ch is ('/' | '?' | '#') {
return (s[0:i].to_owned(), s[i:].to_owned())
}
}
(s, "")
}
///|
fn strip_fragment(s : String) -> String {
match find_char(s, '#') {
Some(idx) => s[0:idx].to_owned()
None => s
}
}
///|
fn strip_query(s : String) -> String {
match find_char(s, '?') {
Some(idx) => s[0:idx].to_owned()
None => s
}
}
///|
/// Split a relative reference into path and query/fragment suffix.
fn split_path_suffix(s : String) -> (String, String) {
for i, ch in s {
if ch is ('?' | '#') {
return (s[0:i].to_owned(), s[i:].to_owned())
}
}
(s, "")
}
///|
/// Merge a relative reference path against the base path's directory.
fn merge_relative_path(base_path : String, raw_path : String) -> String {
let dir = if base_path is "" {
"/"
} else {
match last_index_char(base_path, '/') {
Some(idx) => base_path[0:idx + 1].to_owned()
None => "/"
}
}
dir + raw_path
}
///|
/// Remove "." and ".." path segments after RFC 3986 section 5.2.4.
fn remove_dot_segments(path : String) -> String {
let leading_slash = path.has_prefix("/")
let trailing_slash = path.has_suffix("/") ||
path.has_suffix("/.") ||
path.has_suffix("/..")
let segments : Array[String] = []
let mut first = true
for part_view in path.split("/") {
let part = part_view.to_owned()
if first && leading_slash && part is "" {
first = false
continue
}
first = false
match part {
"." => ()
".." => ignore(segments.pop())
_ => segments.push(part)
}
}
let joined = segments.join("/")
let normalized = if leading_slash { "/" + joined } else { joined }
if trailing_slash && normalized != "/" {
normalized + "/"
} else {
normalized
}
}
///|
/// Split off a leading `scheme://` (returns the prefix incl. "//" and the
/// remainder), or ("", domain) when there is none.
fn split_scheme(domain : String) -> (String, String) {
match url_scheme(domain) {
Some(scheme) => {
let prefix_len = scheme.length() + 1 // scheme + ':'
let rest = domain[prefix_len:].to_owned()
if rest.has_prefix("//") {
(domain[0:prefix_len + 2].to_owned(), rest[2:].to_owned())
} else {
(domain[0:prefix_len].to_owned(), rest)
}
}
None =>
if domain.has_prefix("//") {
("//", domain[2:].to_owned())
} else {
("", domain)
}
}
}
///|
fn find_char(s : String, target : Char) -> Int? {
for i, ch in s {
if ch == target {
return Some(i)
}
}
None
}
///|
fn last_index_char(s : String, target : Char) -> Int? {
let mut found : Int? = None
for i, ch in s {
if ch == target {
found = Some(i)
}
}
found
}