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}