js-routes 2.3.7 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/router.ts ADDED
@@ -0,0 +1,665 @@
1
+ type Optional<T> = { [P in keyof T]?: T[P] | null };
2
+ export type Collection<T> = Record<string, T>;
3
+
4
+ type BaseRouteParameter = string | boolean | Date | number | bigint;
5
+ type MethodRouteParameter = BaseRouteParameter | (() => BaseRouteParameter);
6
+ type ModelRouteParameter =
7
+ | { id: MethodRouteParameter }
8
+ | { to_param: MethodRouteParameter }
9
+ | { toParam: MethodRouteParameter };
10
+ type RequiredRouteParameter = BaseRouteParameter | ModelRouteParameter;
11
+ type OptionalRouteParameter = undefined | null | RequiredRouteParameter;
12
+ type QueryRouteParameter =
13
+ | OptionalRouteParameter
14
+ | QueryRouteParameter[]
15
+ | { [k: string]: QueryRouteParameter };
16
+ export type RouteParameters = Collection<QueryRouteParameter>;
17
+
18
+ type Serializable = Collection<unknown>;
19
+ export type Serializer = (value: Serializable) => string;
20
+
21
+ type RouteHelperExtras = {
22
+ requiredParams(): string[];
23
+ toString(): string;
24
+ };
25
+
26
+ type RequiredParameters<T extends number> = T extends 1
27
+ ? [RequiredRouteParameter]
28
+ : T extends 2
29
+ ? [RequiredRouteParameter, RequiredRouteParameter]
30
+ : T extends 3
31
+ ? [RequiredRouteParameter, RequiredRouteParameter, RequiredRouteParameter]
32
+ : T extends 4
33
+ ? [
34
+ RequiredRouteParameter,
35
+ RequiredRouteParameter,
36
+ RequiredRouteParameter,
37
+ RequiredRouteParameter,
38
+ ]
39
+ : RequiredRouteParameter[];
40
+
41
+ type RouteHelperOptions = RouteOptions & Collection<OptionalRouteParameter>;
42
+
43
+ export type RouteHelper<T extends number = number> = ((
44
+ ...args: [...RequiredParameters<T>, RouteHelperOptions]
45
+ ) => string) &
46
+ RouteHelperExtras;
47
+
48
+ type Configuration = {
49
+ prefix: string;
50
+ default_url_options: RouteParameters;
51
+ special_options_key: string;
52
+ serializer: Serializer;
53
+ deprecated_false_parameter_behavior: boolean;
54
+ deprecated_nil_query_parameter_behavior: boolean;
55
+ include_undefined_query_parameters: boolean;
56
+ };
57
+ export interface RouterExposedMethods {
58
+ config(): Configuration;
59
+ configure(arg: Partial<Configuration>): Configuration;
60
+ serialize: Serializer;
61
+ __route(...args: unknown[]): RouteHelper;
62
+ }
63
+ export interface RouterConstructor {
64
+ new (config?: Partial<Configuration>): RouterExposedMethods;
65
+ }
66
+
67
+ type KeywordUrlOptions = Optional<{
68
+ host: string;
69
+ protocol: string;
70
+ subdomain: string;
71
+ port: string | number;
72
+ anchor: string;
73
+ trailing_slash: boolean;
74
+ script_name: string;
75
+ params: RouteParameters;
76
+ }>;
77
+
78
+ type RouteOptions = KeywordUrlOptions & RouteParameters;
79
+
80
+ type PartsTable = Collection<{
81
+ r?: boolean;
82
+ d?: OptionalRouteParameter;
83
+ }>;
84
+
85
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
86
+ const Router: RouterConstructor = (() => {
87
+ enum NodeTypes {
88
+ GROUP = 1,
89
+ CAT = 2,
90
+ SYMBOL = 3,
91
+ OR = 4,
92
+ STAR = 5,
93
+ LITERAL = 6,
94
+ SLASH = 7,
95
+ DOT = 8,
96
+ }
97
+ type RouteNodes = {
98
+ [NodeTypes.GROUP]: { left: RouteTree; right: never };
99
+ [NodeTypes.STAR]: { left: RouteTree; right: never };
100
+ [NodeTypes.LITERAL]: { left: string; right: never };
101
+ [NodeTypes.SLASH]: { left: "/"; right: never };
102
+ [NodeTypes.DOT]: { left: "."; right: never };
103
+ [NodeTypes.CAT]: { left: RouteTree; right: RouteTree };
104
+ [NodeTypes.SYMBOL]: { left: string; right: never };
105
+ };
106
+ type RouteNode<T extends keyof RouteNodes> = [
107
+ T,
108
+ RouteNodes[T]["left"],
109
+ RouteNodes[T]["right"],
110
+ ];
111
+ type RouteTree = {
112
+ [T in keyof RouteNodes]: RouteNode<T>;
113
+ }[keyof RouteNodes];
114
+
115
+ const isBrowser = typeof window !== "undefined";
116
+
117
+ const UnescapedSpecials = "-._~!$&'()*+,;=:@"
118
+ .split("")
119
+ .map((s) => s.charCodeAt(0));
120
+ const UnescapedRanges = [
121
+ ["a", "z"],
122
+ ["A", "Z"],
123
+ ["0", "9"],
124
+ ].map((range) => range.map((s) => s.charCodeAt(0)));
125
+
126
+ class ParametersMissing extends Error {
127
+ readonly keys: string[];
128
+ constructor(...keys: string[]) {
129
+ super(`Route missing required keys: ${keys.join(", ")}`);
130
+ this.keys = keys;
131
+ Object.setPrototypeOf(this, Object.getPrototypeOf(this));
132
+ this.name = ParametersMissing.name;
133
+ }
134
+ }
135
+
136
+ const ReservedOptions = [
137
+ "anchor",
138
+ "trailing_slash",
139
+ "subdomain",
140
+ "host",
141
+ "port",
142
+ "protocol",
143
+ "script_name",
144
+ ] as const;
145
+
146
+ type ReservedOption = (typeof ReservedOptions)[any];
147
+
148
+ class Router implements RouterExposedMethods {
149
+ private configuration: Configuration = {
150
+ prefix: "",
151
+ default_url_options: {},
152
+ special_options_key: "__options__",
153
+ serializer: this.default_serializer.bind(this),
154
+ deprecated_false_parameter_behavior: false,
155
+ deprecated_nil_query_parameter_behavior: false,
156
+ include_undefined_query_parameters: true,
157
+ };
158
+
159
+ constructor(config: Partial<Configuration> = {}) {
160
+ this.configure(config);
161
+ }
162
+
163
+ configure(new_config: Partial<Configuration>): Configuration {
164
+ if (new_config.prefix) {
165
+ console.warn(
166
+ "JsRoutes configuration prefix option is deprecated in favor of default_url_options.script_name.",
167
+ );
168
+ }
169
+ new_config.serializer ??= this.default_serializer.bind(this);
170
+ this.configuration = { ...this.configuration, ...new_config };
171
+ return this.configuration;
172
+ }
173
+
174
+ config(): Configuration {
175
+ return { ...this.configuration };
176
+ }
177
+
178
+ serialize(object: Serializable): string {
179
+ return this.configuration.serializer(object);
180
+ }
181
+
182
+ __route(
183
+ parts_table: PartsTable,
184
+ route_spec: RouteTree,
185
+ absolute = false,
186
+ ): RouteHelper {
187
+ const required_params: string[] = [];
188
+ const parts: string[] = [];
189
+ const default_options: RouteParameters = {};
190
+ for (const [part, { r: required, d: value }] of Object.entries(
191
+ parts_table,
192
+ )) {
193
+ parts.push(part);
194
+ if (required) {
195
+ required_params.push(part);
196
+ }
197
+ if (this.is_not_nullable(value)) {
198
+ default_options[part] = value;
199
+ }
200
+ }
201
+ const result = (...args: OptionalRouteParameter[]): string => {
202
+ return this.build_route(
203
+ parts,
204
+ required_params,
205
+ default_options,
206
+ route_spec,
207
+ absolute,
208
+ args,
209
+ );
210
+ };
211
+ result.requiredParams = () => required_params;
212
+ result.toString = () => {
213
+ return this.build_path_spec(route_spec);
214
+ };
215
+ return result as any;
216
+ }
217
+
218
+ private default_serializer(
219
+ value: unknown,
220
+ prefix?: string | null,
221
+ array_element = false,
222
+ ): string {
223
+ if (!prefix && !this.is_object(value)) {
224
+ throw new Error("URL parameters should be a javascript hash");
225
+ }
226
+ prefix = prefix || "";
227
+ const result: string[] = [];
228
+ if (this.is_array(value)) {
229
+ // Rails serializes an empty array nested inside another array as a nil
230
+ // query value, while an empty object contributes no query parameter.
231
+ if (value.length === 0) {
232
+ return array_element
233
+ ? this.default_serializer(null, prefix + "[]")
234
+ : "";
235
+ }
236
+ for (const element of value) {
237
+ const subvalue = this.default_serializer(
238
+ element,
239
+ prefix + "[]",
240
+ true,
241
+ );
242
+ // Skip empty object results so arrays do not produce leading,
243
+ // trailing, or doubled "&" separators.
244
+ if (subvalue.length) {
245
+ result.push(subvalue);
246
+ }
247
+ }
248
+ } else if (this.is_object(value)) {
249
+ for (let key in value) {
250
+ if (!this.hasProp(value, key)) continue;
251
+ let prop = value[key];
252
+ if (
253
+ !this.configuration.include_undefined_query_parameters &&
254
+ prop === undefined
255
+ ) {
256
+ continue;
257
+ }
258
+ if (prefix) {
259
+ key = prefix + "[" + key + "]";
260
+ }
261
+ const subvalue = this.default_serializer(prop, key);
262
+ if (subvalue.length) {
263
+ result.push(subvalue);
264
+ }
265
+ }
266
+ } else {
267
+ const key = encodeURIComponent(prefix);
268
+ result.push(
269
+ this.is_not_nullable(value) ||
270
+ this.configuration.deprecated_nil_query_parameter_behavior
271
+ ? key + "=" + encodeURIComponent("" + (value ?? ""))
272
+ : key,
273
+ );
274
+ }
275
+ return result.join("&");
276
+ }
277
+
278
+ private extract_options(
279
+ number_of_params: number,
280
+ args: OptionalRouteParameter[],
281
+ ): {
282
+ args: OptionalRouteParameter[];
283
+ options: RouteOptions;
284
+ } {
285
+ const last_el = args[args.length - 1];
286
+ if (
287
+ (args.length > number_of_params && last_el === 0) ||
288
+ (this.is_object(last_el) && !this.looks_like_serialized_model(last_el))
289
+ ) {
290
+ if (this.is_object(last_el)) {
291
+ delete last_el[this.configuration.special_options_key];
292
+ }
293
+ return {
294
+ args: args.slice(0, args.length - 1),
295
+ options: last_el as unknown as RouteOptions,
296
+ };
297
+ } else {
298
+ return { args, options: {} };
299
+ }
300
+ }
301
+
302
+ private looks_like_serialized_model(
303
+ object: unknown,
304
+ ): object is ModelRouteParameter {
305
+ return (
306
+ this.is_object(object) &&
307
+ !(this.configuration.special_options_key in object) &&
308
+ ("id" in object || "to_param" in object || "toParam" in object)
309
+ );
310
+ }
311
+
312
+ private path_identifier(object: QueryRouteParameter): string {
313
+ const result = this.unwrap_path_identifier(object);
314
+ return this.is_nullable(result) ||
315
+ (this.configuration.deprecated_false_parameter_behavior &&
316
+ result === false)
317
+ ? ""
318
+ : "" + result;
319
+ }
320
+
321
+ private unwrap_path_identifier(object: QueryRouteParameter): unknown {
322
+ let result: unknown = object;
323
+ if (!this.is_object(object)) {
324
+ return object;
325
+ }
326
+ if ("to_param" in object) {
327
+ result = object.to_param;
328
+ } else if ("toParam" in object) {
329
+ result = object.toParam;
330
+ } else if ("id" in object) {
331
+ result = object.id;
332
+ } else {
333
+ result = object;
334
+ }
335
+ return this.is_callable(result) ? result.call(object) : result;
336
+ }
337
+
338
+ private partition_parameters(
339
+ parts: string[],
340
+ required_params: string[],
341
+ default_options: RouteParameters,
342
+ call_arguments: OptionalRouteParameter[],
343
+ ): {
344
+ keyword_parameters: KeywordUrlOptions;
345
+ query_parameters: RouteParameters;
346
+ } {
347
+ let { args, options } = this.extract_options(
348
+ parts.length,
349
+ call_arguments,
350
+ );
351
+ if (args.length > parts.length) {
352
+ throw new Error("Too many parameters provided for path");
353
+ }
354
+ let use_all_parts = args.length > required_params.length;
355
+ const parts_options: RouteParameters = {
356
+ ...this.configuration.default_url_options,
357
+ };
358
+ for (const key in options) {
359
+ const value = options[key];
360
+ if (!this.hasProp(options, key)) continue;
361
+ use_all_parts = true;
362
+ if (parts.includes(key)) {
363
+ parts_options[key] = value;
364
+ }
365
+ }
366
+ options = {
367
+ ...this.configuration.default_url_options,
368
+ ...default_options,
369
+ ...options,
370
+ };
371
+
372
+ const keyword_parameters: KeywordUrlOptions = {};
373
+ let query_parameters: RouteParameters = {};
374
+ for (const key in options) {
375
+ if (!this.hasProp(options, key)) continue;
376
+ const value = options[key];
377
+ if (key === "params") {
378
+ if (this.is_object(value)) {
379
+ query_parameters = {
380
+ ...query_parameters,
381
+ ...(value as RouteParameters),
382
+ };
383
+ } else {
384
+ throw new Error("params value should always be an object");
385
+ }
386
+ } else if (this.is_reserved_option(key)) {
387
+ keyword_parameters[key] = value as any;
388
+ } else {
389
+ if (
390
+ !this.is_nullable(value) &&
391
+ (value !== default_options[key] || required_params.includes(key))
392
+ ) {
393
+ query_parameters[key] = value;
394
+ }
395
+ }
396
+ }
397
+ const route_parts = use_all_parts ? parts : required_params;
398
+ let i = 0;
399
+ for (const part of route_parts) {
400
+ if (i < args.length) {
401
+ const value = args[i];
402
+ if (!this.hasProp(parts_options, part)) {
403
+ query_parameters[part] = value;
404
+ ++i;
405
+ }
406
+ }
407
+ }
408
+ return { keyword_parameters, query_parameters };
409
+ }
410
+
411
+ private build_route(
412
+ parts: string[],
413
+ required_params: string[],
414
+ default_options: RouteParameters,
415
+ route: RouteTree,
416
+ absolute: boolean,
417
+ args: OptionalRouteParameter[],
418
+ ): string {
419
+ const { keyword_parameters, query_parameters } =
420
+ this.partition_parameters(
421
+ parts,
422
+ required_params,
423
+ default_options,
424
+ args,
425
+ );
426
+
427
+ let { trailing_slash, anchor, script_name } = keyword_parameters;
428
+ const missing_params = required_params.filter(
429
+ (param) =>
430
+ !this.hasProp(query_parameters, param) ||
431
+ this.is_nullable(query_parameters[param]),
432
+ );
433
+ if (missing_params.length) {
434
+ throw new ParametersMissing(...missing_params);
435
+ }
436
+ let result = this.get_prefix() + this.visit(route, query_parameters);
437
+ if (trailing_slash) {
438
+ result = result.replace(/(.*?)[/]?$/, "$1/");
439
+ }
440
+ const url_params = this.serialize(query_parameters);
441
+ if (url_params.length) {
442
+ result += "?" + url_params;
443
+ }
444
+ if (anchor) {
445
+ result += "#" + anchor;
446
+ }
447
+ if (script_name) {
448
+ const last_index = script_name.length - 1;
449
+ if (script_name[last_index] == "/" && result[0] == "/") {
450
+ script_name = script_name.slice(0, last_index);
451
+ }
452
+ result = script_name + result;
453
+ }
454
+ if (absolute) {
455
+ result = this.route_url(keyword_parameters) + result;
456
+ }
457
+ return result;
458
+ }
459
+
460
+ private visit(
461
+ route: RouteTree,
462
+ parameters: RouteParameters,
463
+ optional = false,
464
+ ): string {
465
+ switch (route[0]) {
466
+ case NodeTypes.GROUP:
467
+ return this.visit(route[1], parameters, true);
468
+ case NodeTypes.CAT:
469
+ return this.visit_cat(route, parameters, optional);
470
+ case NodeTypes.SYMBOL:
471
+ return this.visit_symbol(route, parameters, optional);
472
+ case NodeTypes.STAR:
473
+ return this.visit_globbing(route[1], parameters, true);
474
+ case NodeTypes.LITERAL:
475
+ case NodeTypes.SLASH:
476
+ case NodeTypes.DOT:
477
+ return route[1];
478
+ default:
479
+ throw new Error("Unknown Rails node type");
480
+ }
481
+ }
482
+
483
+ private is_not_nullable<T>(object: T): object is NonNullable<T> {
484
+ return !this.is_nullable(object);
485
+ }
486
+
487
+ private is_nullable(object: unknown): object is null | undefined {
488
+ return object === undefined || object === null;
489
+ }
490
+
491
+ private visit_cat(
492
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
493
+ [_type, left, right]: RouteNode<NodeTypes.CAT>,
494
+ parameters: RouteParameters,
495
+ optional: boolean,
496
+ ): string {
497
+ const left_part = this.visit(left, parameters, optional);
498
+ let right_part = this.visit(right, parameters, optional);
499
+ if (
500
+ optional &&
501
+ ((this.is_optional_node(left[0]) && !left_part) ||
502
+ (this.is_optional_node(right[0]) && !right_part))
503
+ ) {
504
+ return "";
505
+ }
506
+ // if left_part ends on '/' and right_part starts on '/'
507
+ if (left_part[left_part.length - 1] === "/" && right_part[0] === "/") {
508
+ // strip slash from right_part
509
+ // to prevent double slash
510
+ right_part = right_part.substring(1);
511
+ }
512
+ return left_part + right_part;
513
+ }
514
+
515
+ private visit_symbol(
516
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
517
+ [_type, key]: RouteNode<NodeTypes.SYMBOL>,
518
+ parameters: RouteParameters,
519
+ optional: boolean,
520
+ ): string {
521
+ const value = this.path_identifier(parameters[key]);
522
+ delete parameters[key];
523
+ if (value.length) {
524
+ return this.encode_segment(value);
525
+ }
526
+ if (optional) {
527
+ return "";
528
+ } else {
529
+ throw new ParametersMissing(key);
530
+ }
531
+ }
532
+
533
+ private encode_segment(segment: string): string {
534
+ if (segment.match(/^[a-zA-Z0-9-]$/)) {
535
+ // Performance optimization for 99% of cases
536
+ return segment;
537
+ }
538
+ return (segment.match(/./gu) || [])
539
+ .map((ch) => {
540
+ const code = ch.charCodeAt(0);
541
+ if (
542
+ UnescapedRanges.find(
543
+ (range) => code >= range[0] && code <= range[1],
544
+ ) ||
545
+ UnescapedSpecials.includes(code)
546
+ ) {
547
+ return ch;
548
+ } else {
549
+ return encodeURIComponent(ch);
550
+ }
551
+ })
552
+ .join("");
553
+ }
554
+
555
+ private is_optional_node(node: NodeTypes): boolean {
556
+ return [NodeTypes.STAR, NodeTypes.SYMBOL, NodeTypes.CAT].includes(node);
557
+ }
558
+
559
+ private build_path_spec(route: RouteTree, wildcard = false): string {
560
+ let key: string;
561
+ switch (route[0]) {
562
+ case NodeTypes.GROUP:
563
+ return `(${this.build_path_spec(route[1])})`;
564
+ case NodeTypes.CAT:
565
+ return (
566
+ this.build_path_spec(route[1]) + this.build_path_spec(route[2])
567
+ );
568
+ case NodeTypes.STAR:
569
+ return this.build_path_spec(route[1], true);
570
+ case NodeTypes.SYMBOL:
571
+ key = route[1];
572
+ if (wildcard) {
573
+ return (key.startsWith("*") ? "" : "*") + key;
574
+ } else {
575
+ return ":" + key;
576
+ }
577
+ case NodeTypes.SLASH:
578
+ case NodeTypes.DOT:
579
+ case NodeTypes.LITERAL:
580
+ return route[1];
581
+ default:
582
+ throw new Error("Unknown Rails node type");
583
+ }
584
+ }
585
+
586
+ private visit_globbing(
587
+ route: RouteTree,
588
+ parameters: RouteParameters,
589
+ optional: boolean,
590
+ ): string {
591
+ const key = route[1] as string;
592
+ let value = parameters[key];
593
+ delete parameters[key];
594
+ if (this.is_nullable(value)) {
595
+ return this.visit(route, parameters, optional);
596
+ }
597
+ if (this.is_array(value)) {
598
+ value = value.join("/");
599
+ }
600
+ const result = this.path_identifier(value as any);
601
+ return encodeURI(result);
602
+ }
603
+
604
+ private get_prefix(): string {
605
+ const prefix = this.configuration.prefix;
606
+ return prefix.match("/$")
607
+ ? prefix.substring(0, prefix.length - 1)
608
+ : prefix;
609
+ }
610
+
611
+ private route_url(route_defaults: KeywordUrlOptions): string {
612
+ const hostname = route_defaults.host || this.current_host();
613
+ if (!hostname) {
614
+ return "";
615
+ }
616
+ const subdomain = route_defaults.subdomain
617
+ ? route_defaults.subdomain + "."
618
+ : "";
619
+ const protocol = route_defaults.protocol || this.current_protocol();
620
+ let port =
621
+ route_defaults.port ||
622
+ (!route_defaults.host ? this.current_port() : undefined);
623
+ port = port ? ":" + port : "";
624
+ return protocol + "://" + subdomain + hostname + port;
625
+ }
626
+
627
+ private current_host(): string {
628
+ return (isBrowser && window?.location?.hostname) || "";
629
+ }
630
+
631
+ private current_protocol(): string {
632
+ return (
633
+ (isBrowser && window?.location?.protocol?.replace(/:$/, "")) || "http"
634
+ );
635
+ }
636
+
637
+ private current_port(): string {
638
+ return (isBrowser && window?.location?.port) || "";
639
+ }
640
+
641
+ private is_object(value: unknown): value is Collection<unknown> {
642
+ return (
643
+ typeof value === "object" &&
644
+ Object.prototype.toString.call(value) === "[object Object]"
645
+ );
646
+ }
647
+
648
+ private is_array<T>(object: unknown | T[]): object is T[] {
649
+ return object instanceof Array;
650
+ }
651
+
652
+ private is_callable(object: unknown): object is Function {
653
+ return typeof object === "function" && !!object.call;
654
+ }
655
+
656
+ private is_reserved_option(key: unknown): key is ReservedOption {
657
+ return ReservedOptions.includes(key as any);
658
+ }
659
+
660
+ private hasProp(value: unknown, key: string): boolean {
661
+ return Object.prototype.hasOwnProperty.call(value, key);
662
+ }
663
+ }
664
+ return Router;
665
+ })();