this repo has no description
1/** 2 * Created by keithpk on 12/2/16. 3 */ 4 5import { isNothing, Nothing, Opt } from "@jet/environment/types/optional"; 6import { isDefinedNonNullNonEmpty, isNullOrEmpty } from "./server-data"; 7 8export type Query = { 9 [key: string]: string | undefined; 10}; 11 12export type URLComponent = "protocol" | "username" | "password" | "host" | "port" | "pathname" | "query" | "hash"; 13 14const protocolRegex = /^([a-z][a-z0-9.+-]*:)(\/\/)?([\S\s]*)/i; 15const queryParamRegex = /([^=?&]+)=?([^&]*)/g; 16const componentOrder: URLComponent[] = ["hash", "query", "pathname", "host"]; 17 18type URLSplitStyle = "prefix" | "suffix"; 19 20type URLSplitResult = { 21 result?: string; 22 remainder: string; 23}; 24 25function splitUrlComponent(input: string, marker: string, style: URLSplitStyle): URLSplitResult { 26 const index = input.indexOf(marker); 27 let result; 28 let remainder = input; 29 if (index !== -1) { 30 const prefix = input.slice(0, index); 31 const suffix = input.slice(index + marker.length, input.length); 32 33 if (style === "prefix") { 34 result = prefix; 35 remainder = suffix; 36 } else { 37 result = suffix; 38 remainder = prefix; 39 } 40 } 41 42 // log("Token: " + marker + " String: " + input, " Result: " + result + " Remainder: " + remainder) 43 44 return { 45 result: result, 46 remainder: remainder, 47 }; 48} 49 50export class URL { 51 protocol?: Opt<string>; 52 username: string; 53 password: string; 54 host?: Opt<string>; 55 port: string; 56 pathname?: Opt<string>; 57 query?: Query = {}; 58 hash?: string; 59 60 constructor(url?: string) { 61 if (isNullOrEmpty(url)) { 62 return; 63 } 64 65 // Split the protocol from the rest of the urls 66 let remainder = url; 67 const match = protocolRegex.exec(url); 68 if (match != null) { 69 // Pull out the protocol 70 let protocol = match[1]; 71 if (protocol) { 72 protocol = protocol.split(":")[0]; 73 } 74 75 this.protocol = protocol; 76 77 // Save the remainder 78 remainder = match[3]; 79 } 80 81 // Then match each component in a specific order 82 let parse: URLSplitResult = { remainder: remainder, result: undefined }; 83 for (const component of componentOrder) { 84 if (!parse.remainder) { 85 break; 86 } 87 88 switch (component) { 89 case "hash": { 90 parse = splitUrlComponent(parse.remainder, "#", "suffix"); 91 this.hash = parse.result; 92 break; 93 } 94 case "query": { 95 parse = splitUrlComponent(parse.remainder, "?", "suffix"); 96 if (isDefinedNonNullNonEmpty(parse.result)) { 97 this.query = URL.queryFromString(parse.result); 98 } 99 break; 100 } 101 case "pathname": { 102 parse = splitUrlComponent(parse.remainder, "/", "suffix"); 103 104 if (isDefinedNonNullNonEmpty(parse.result)) { 105 // Replace the initial /, since paths require it 106 this.pathname = "/" + parse.result; 107 } 108 break; 109 } 110 case "host": { 111 if (parse.remainder) { 112 const authorityParse = splitUrlComponent(parse.remainder, "@", "prefix"); 113 const userInfo = authorityParse.result; 114 const hostPort = authorityParse.remainder; 115 if (isDefinedNonNullNonEmpty(userInfo)) { 116 const userInfoSplit = userInfo.split(":"); 117 this.username = decodeURIComponent(userInfoSplit[0]); 118 this.password = decodeURIComponent(userInfoSplit[1]); 119 } 120 121 if (hostPort) { 122 const hostPortSplit = hostPort.split(":"); 123 this.host = hostPortSplit[0]; 124 this.port = hostPortSplit[1]; 125 } 126 } 127 break; 128 } 129 default: { 130 throw new Error("Unhandled case!"); 131 } 132 } 133 } 134 } 135 136 set(component: URLComponent, value: string | Query): URL { 137 if (isNullOrEmpty(value)) { 138 return this; 139 } 140 141 if (component === "query") { 142 if (typeof value === "string") { 143 value = URL.queryFromString(value); 144 } 145 } 146 147 switch (component) { 148 // Exhaustive match to make sure TS property minifiers and other 149 // transformer plugins do not break this code. 150 case "protocol": 151 this.protocol = value as string; 152 break; 153 case "username": 154 this.username = value as string; 155 break; 156 case "password": 157 this.password = value as string; 158 break; 159 case "port": 160 this.port = value as string; 161 break; 162 case "pathname": 163 this.pathname = value as string; 164 break; 165 case "query": 166 this.query = value as Query; 167 break; 168 case "hash": 169 this.hash = value as string; 170 break; 171 default: 172 // The fallback for component which is not a property of URL object. 173 this[component] = value as string; 174 break; 175 } 176 return this; 177 } 178 179 private get(component: URLComponent): string | Query | Nothing { 180 switch (component) { 181 // Exhaustive match to make sure TS property minifiers and other 182 // transformer plugins do not break this code. 183 case "protocol": 184 return this.protocol; 185 case "username": 186 return this.username; 187 case "password": 188 return this.password; 189 case "port": 190 return this.port; 191 case "pathname": 192 return this.pathname; 193 case "query": 194 return this.query; 195 case "hash": 196 return this.hash; 197 default: 198 // The fallback for component which is not a property of URL object. 199 return this[component]; 200 } 201 } 202 203 append(component: URLComponent, value: string | Query): URL { 204 const existingValue = this.get(component); 205 let newValue; 206 207 if (component === "query") { 208 if (typeof value === "string") { 209 value = URL.queryFromString(value); 210 } 211 212 if (typeof existingValue === "string") { 213 newValue = { existingValue, ...value }; 214 } else { 215 newValue = { ...existingValue, ...value }; 216 } 217 } else { 218 let existingValueString = existingValue as string; 219 220 if (!existingValueString) { 221 existingValueString = ""; 222 } 223 224 let newValueString = existingValueString; 225 226 if (component === "pathname") { 227 const pathLength = existingValueString.length; 228 if (!pathLength || existingValueString[pathLength - 1] !== "/") { 229 newValueString += "/"; 230 } 231 } 232 233 // eslint-disable-next-line @typescript-eslint/restrict-plus-operands, @typescript-eslint/no-base-to-string 234 newValueString += value; 235 newValue = newValueString; 236 } 237 238 return this.set(component, newValue); 239 } 240 241 param(key: string, value?: string): URL { 242 if (!key) { 243 return this; 244 } 245 if (this.query == null) { 246 this.query = {}; 247 } 248 this.query[key] = value; 249 return this; 250 } 251 252 removeParam(key: string): URL { 253 if (!key || this.query == null) { 254 return this; 255 } 256 if (this.query[key] !== undefined) { 257 delete this.query[key]; 258 } 259 return this; 260 } 261 262 /** 263 * Push a new string value onto the path for this url 264 * @returns URL this object with the updated path. 265 */ 266 path(value: string): URL { 267 return this.append("pathname", value); 268 } 269 270 pathExtension(): Opt<string> { 271 // Extract path extension if one exists 272 if (isNothing(this.pathname)) { 273 return null; 274 } 275 276 const lastFilenameComponents = this.pathname 277 .split("/") 278 .filter((item) => item.length > 0) // Remove any double or trailing slashes 279 .pop() 280 ?.split("."); 281 if (lastFilenameComponents === undefined) { 282 return null; 283 } 284 if ( 285 lastFilenameComponents.filter((part) => { 286 return part !== ""; 287 }).length < 2 // Remove any empty parts (e.g. .ssh_config -> ["ssh_config"]) 288 ) { 289 return null; 290 } 291 292 return lastFilenameComponents.pop(); 293 } 294 295 /** 296 * Returns the path components of the URL 297 * @returns An array of non-empty path components from `urls`. 298 */ 299 pathComponents(): string[] { 300 if (isNullOrEmpty(this.pathname)) { 301 return []; 302 } 303 304 return this.pathname.split("/").filter((component) => component.length > 0); 305 } 306 307 /** 308 * Returns the last path component from this url, updating the url to not include this path component 309 * @returns String the last path component from this url. 310 */ 311 popPathComponent(): string | null { 312 if (isNullOrEmpty(this.pathname)) { 313 return null; 314 } 315 316 const lastPathComponent = this.pathname.slice(this.pathname.lastIndexOf("/") + 1); 317 318 if (lastPathComponent.length === 0) { 319 return null; 320 } 321 322 this.pathname = this.pathname.slice(0, this.pathname.lastIndexOf("/")); 323 324 return lastPathComponent; 325 } 326 327 /** 328 * Same as toString 329 * 330 * @returns {string} A string representation of the URL 331 */ 332 build(): string { 333 return this.toString(); 334 } 335 336 /** 337 * Converts the URL to a string 338 * 339 * @returns {string} A string representation of the URL 340 */ 341 toString(): string { 342 let url = ""; 343 344 if (isDefinedNonNullNonEmpty(this.protocol)) { 345 url += this.protocol + "://"; 346 } 347 348 if (this.username) { 349 url += encodeURIComponent(this.username); 350 351 if (this.password) { 352 url += ":" + encodeURIComponent(this.password); 353 } 354 355 url += "@"; 356 } 357 358 if (isDefinedNonNullNonEmpty(this.host)) { 359 url += this.host; 360 361 if (this.port) { 362 url += ":" + this.port; 363 } 364 } 365 366 if (isDefinedNonNullNonEmpty(this.pathname)) { 367 url += this.pathname; 368 /// Trim off trailing path separators when we have a valid path 369 /// We don't do this unless pathname has elements otherwise we will trim the `://` 370 if (url.endsWith("/") && this.pathname.length > 0) { 371 url = url.slice(0, -1); 372 } 373 } 374 375 if (this.query != null && Object.keys(this.query).length > 0) { 376 url += "?" + URL.toQueryString(this.query); 377 } 378 379 if (isDefinedNonNullNonEmpty(this.hash)) { 380 url += "#" + this.hash; 381 } 382 383 return url; 384 } 385 386 // ---------------- 387 // Static API 388 // ---------------- 389 390 /** 391 * Converts a string into a query dictionary 392 * @param query The string to parse 393 * @returns The query dictionary containing the key-value pairs in the query string 394 */ 395 static queryFromString(query: string): Query { 396 const result = {}; 397 398 let parseResult = queryParamRegex.exec(query); 399 while (parseResult != null) { 400 const key = decodeURIComponent(parseResult[1]); 401 const value = decodeURIComponent(parseResult[2]); 402 result[key] = value; 403 parseResult = queryParamRegex.exec(query); 404 } 405 406 return result; 407 } 408 409 /** 410 * Converts a query dictionary into a query string 411 * 412 * @param query The query dictionary 413 * @returns {string} The string representation of the query dictionary 414 */ 415 static toQueryString(query: Query) { 416 let queryString = ""; 417 418 let first = true; 419 for (const key of Object.keys(query)) { 420 if (!first) { 421 queryString += "&"; 422 } 423 first = false; 424 425 queryString += encodeURIComponent(key); 426 427 const value = query[key]; 428 if (isDefinedNonNullNonEmpty(value) && value.length) { 429 queryString += "=" + encodeURIComponent(value); 430 } 431 } 432 433 return queryString; 434 } 435 436 /** 437 * Convenience method to instantiate a URL from a string 438 * @param url The URL string to parse 439 * @returns {URL} The new URL object representing the URL 440 */ 441 static from(url: string): URL { 442 return new URL(url); 443 } 444 445 /** 446 * Convenience method to instantiate a URL from numerous (optional) components 447 * @param protocol The protocol type 448 * @param host The host name 449 * @param path The path 450 * @param query The query 451 * @param hash The hash 452 * @returns {URL} The new URL object representing the URL 453 */ 454 static fromComponents( 455 protocol?: Opt<string>, 456 host?: Opt<string>, 457 path?: Opt<string>, 458 query?: Query, 459 hash?: string, 460 ): URL { 461 const url = new URL(); 462 url.protocol = protocol; 463 url.host = host; 464 url.pathname = path; 465 url.query = query; 466 url.hash = hash; 467 return url; 468 } 469}