/** * Created by keithpk on 12/2/16. */ import { isNothing, Nothing, Opt } from "@jet/environment/types/optional"; import { isDefinedNonNullNonEmpty, isNullOrEmpty } from "./server-data"; export type Query = { [key: string]: string | undefined; }; export type URLComponent = "protocol" | "username" | "password" | "host" | "port" | "pathname" | "query" | "hash"; const protocolRegex = /^([a-z][a-z0-9.+-]*:)(\/\/)?([\S\s]*)/i; const queryParamRegex = /([^=?&]+)=?([^&]*)/g; const componentOrder: URLComponent[] = ["hash", "query", "pathname", "host"]; type URLSplitStyle = "prefix" | "suffix"; type URLSplitResult = { result?: string; remainder: string; }; function splitUrlComponent(input: string, marker: string, style: URLSplitStyle): URLSplitResult { const index = input.indexOf(marker); let result; let remainder = input; if (index !== -1) { const prefix = input.slice(0, index); const suffix = input.slice(index + marker.length, input.length); if (style === "prefix") { result = prefix; remainder = suffix; } else { result = suffix; remainder = prefix; } } // log("Token: " + marker + " String: " + input, " Result: " + result + " Remainder: " + remainder) return { result: result, remainder: remainder, }; } export class URL { protocol?: Opt; username: string; password: string; host?: Opt; port: string; pathname?: Opt; query?: Query = {}; hash?: string; constructor(url?: string) { if (isNullOrEmpty(url)) { return; } // Split the protocol from the rest of the urls let remainder = url; const match = protocolRegex.exec(url); if (match != null) { // Pull out the protocol let protocol = match[1]; if (protocol) { protocol = protocol.split(":")[0]; } this.protocol = protocol; // Save the remainder remainder = match[3]; } // Then match each component in a specific order let parse: URLSplitResult = { remainder: remainder, result: undefined }; for (const component of componentOrder) { if (!parse.remainder) { break; } switch (component) { case "hash": { parse = splitUrlComponent(parse.remainder, "#", "suffix"); this.hash = parse.result; break; } case "query": { parse = splitUrlComponent(parse.remainder, "?", "suffix"); if (isDefinedNonNullNonEmpty(parse.result)) { this.query = URL.queryFromString(parse.result); } break; } case "pathname": { parse = splitUrlComponent(parse.remainder, "/", "suffix"); if (isDefinedNonNullNonEmpty(parse.result)) { // Replace the initial /, since paths require it this.pathname = "/" + parse.result; } break; } case "host": { if (parse.remainder) { const authorityParse = splitUrlComponent(parse.remainder, "@", "prefix"); const userInfo = authorityParse.result; const hostPort = authorityParse.remainder; if (isDefinedNonNullNonEmpty(userInfo)) { const userInfoSplit = userInfo.split(":"); this.username = decodeURIComponent(userInfoSplit[0]); this.password = decodeURIComponent(userInfoSplit[1]); } if (hostPort) { const hostPortSplit = hostPort.split(":"); this.host = hostPortSplit[0]; this.port = hostPortSplit[1]; } } break; } default: { throw new Error("Unhandled case!"); } } } } set(component: URLComponent, value: string | Query): URL { if (isNullOrEmpty(value)) { return this; } if (component === "query") { if (typeof value === "string") { value = URL.queryFromString(value); } } switch (component) { // Exhaustive match to make sure TS property minifiers and other // transformer plugins do not break this code. case "protocol": this.protocol = value as string; break; case "username": this.username = value as string; break; case "password": this.password = value as string; break; case "port": this.port = value as string; break; case "pathname": this.pathname = value as string; break; case "query": this.query = value as Query; break; case "hash": this.hash = value as string; break; default: // The fallback for component which is not a property of URL object. this[component] = value as string; break; } return this; } private get(component: URLComponent): string | Query | Nothing { switch (component) { // Exhaustive match to make sure TS property minifiers and other // transformer plugins do not break this code. case "protocol": return this.protocol; case "username": return this.username; case "password": return this.password; case "port": return this.port; case "pathname": return this.pathname; case "query": return this.query; case "hash": return this.hash; default: // The fallback for component which is not a property of URL object. return this[component]; } } append(component: URLComponent, value: string | Query): URL { const existingValue = this.get(component); let newValue; if (component === "query") { if (typeof value === "string") { value = URL.queryFromString(value); } if (typeof existingValue === "string") { newValue = { existingValue, ...value }; } else { newValue = { ...existingValue, ...value }; } } else { let existingValueString = existingValue as string; if (!existingValueString) { existingValueString = ""; } let newValueString = existingValueString; if (component === "pathname") { const pathLength = existingValueString.length; if (!pathLength || existingValueString[pathLength - 1] !== "/") { newValueString += "/"; } } // eslint-disable-next-line @typescript-eslint/restrict-plus-operands, @typescript-eslint/no-base-to-string newValueString += value; newValue = newValueString; } return this.set(component, newValue); } param(key: string, value?: string): URL { if (!key) { return this; } if (this.query == null) { this.query = {}; } this.query[key] = value; return this; } removeParam(key: string): URL { if (!key || this.query == null) { return this; } if (this.query[key] !== undefined) { delete this.query[key]; } return this; } /** * Push a new string value onto the path for this url * @returns URL this object with the updated path. */ path(value: string): URL { return this.append("pathname", value); } pathExtension(): Opt { // Extract path extension if one exists if (isNothing(this.pathname)) { return null; } const lastFilenameComponents = this.pathname .split("/") .filter((item) => item.length > 0) // Remove any double or trailing slashes .pop() ?.split("."); if (lastFilenameComponents === undefined) { return null; } if ( lastFilenameComponents.filter((part) => { return part !== ""; }).length < 2 // Remove any empty parts (e.g. .ssh_config -> ["ssh_config"]) ) { return null; } return lastFilenameComponents.pop(); } /** * Returns the path components of the URL * @returns An array of non-empty path components from `urls`. */ pathComponents(): string[] { if (isNullOrEmpty(this.pathname)) { return []; } return this.pathname.split("/").filter((component) => component.length > 0); } /** * Returns the last path component from this url, updating the url to not include this path component * @returns String the last path component from this url. */ popPathComponent(): string | null { if (isNullOrEmpty(this.pathname)) { return null; } const lastPathComponent = this.pathname.slice(this.pathname.lastIndexOf("/") + 1); if (lastPathComponent.length === 0) { return null; } this.pathname = this.pathname.slice(0, this.pathname.lastIndexOf("/")); return lastPathComponent; } /** * Same as toString * * @returns {string} A string representation of the URL */ build(): string { return this.toString(); } /** * Converts the URL to a string * * @returns {string} A string representation of the URL */ toString(): string { let url = ""; if (isDefinedNonNullNonEmpty(this.protocol)) { url += this.protocol + "://"; } if (this.username) { url += encodeURIComponent(this.username); if (this.password) { url += ":" + encodeURIComponent(this.password); } url += "@"; } if (isDefinedNonNullNonEmpty(this.host)) { url += this.host; if (this.port) { url += ":" + this.port; } } if (isDefinedNonNullNonEmpty(this.pathname)) { url += this.pathname; /// Trim off trailing path separators when we have a valid path /// We don't do this unless pathname has elements otherwise we will trim the `://` if (url.endsWith("/") && this.pathname.length > 0) { url = url.slice(0, -1); } } if (this.query != null && Object.keys(this.query).length > 0) { url += "?" + URL.toQueryString(this.query); } if (isDefinedNonNullNonEmpty(this.hash)) { url += "#" + this.hash; } return url; } // ---------------- // Static API // ---------------- /** * Converts a string into a query dictionary * @param query The string to parse * @returns The query dictionary containing the key-value pairs in the query string */ static queryFromString(query: string): Query { const result = {}; let parseResult = queryParamRegex.exec(query); while (parseResult != null) { const key = decodeURIComponent(parseResult[1]); const value = decodeURIComponent(parseResult[2]); result[key] = value; parseResult = queryParamRegex.exec(query); } return result; } /** * Converts a query dictionary into a query string * * @param query The query dictionary * @returns {string} The string representation of the query dictionary */ static toQueryString(query: Query) { let queryString = ""; let first = true; for (const key of Object.keys(query)) { if (!first) { queryString += "&"; } first = false; queryString += encodeURIComponent(key); const value = query[key]; if (isDefinedNonNullNonEmpty(value) && value.length) { queryString += "=" + encodeURIComponent(value); } } return queryString; } /** * Convenience method to instantiate a URL from a string * @param url The URL string to parse * @returns {URL} The new URL object representing the URL */ static from(url: string): URL { return new URL(url); } /** * Convenience method to instantiate a URL from numerous (optional) components * @param protocol The protocol type * @param host The host name * @param path The path * @param query The query * @param hash The hash * @returns {URL} The new URL object representing the URL */ static fromComponents( protocol?: Opt, host?: Opt, path?: Opt, query?: Query, hash?: string, ): URL { const url = new URL(); url.protocol = protocol; url.host = host; url.pathname = path; url.query = query; url.hash = hash; return url; } }