@alepha/react 0.5.2 → 0.6.1

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.
@@ -1,742 +0,0 @@
1
- import { $inject, $logger, Alepha, EventEmitter } from "@alepha/core";
2
- import type { UserAccountInfo } from "@alepha/security";
3
- import type { MatchFunction, ParamData } from "path-to-regexp";
4
- import { compile, match } from "path-to-regexp";
5
- import type { ReactNode } from "react";
6
- import { createElement } from "react";
7
- import NestedView from "../components/NestedView";
8
- import { RouterContext } from "../contexts/RouterContext";
9
- import { RouterLayerContext } from "../contexts/RouterLayerContext";
10
- import type { PageDescriptorOptions } from "../descriptors/$page";
11
- import type { HrefLike } from "../hooks/RouterHookApi";
12
-
13
- export class RedirectException extends Error {
14
- constructor(public readonly page: HrefLike) {
15
- super("Redirection");
16
- }
17
- }
18
-
19
- export class Router extends EventEmitter<RouterEvents> {
20
- protected readonly log = $logger();
21
- protected readonly alepha = $inject(Alepha);
22
- protected readonly pages: PageRoute[] = [];
23
- protected notFoundPageRoute?: PageRoute;
24
-
25
- /**
26
- * Get the page by name.
27
- *
28
- * @param name - Page name
29
- * @return PageRoute
30
- */
31
- public page(name: string): PageRoute {
32
- const found = this.pages.find((it) => it.name === name);
33
- if (!found) {
34
- throw new Error(`Page ${name} not found`);
35
- }
36
-
37
- return found;
38
- }
39
-
40
- /**
41
- *
42
- */
43
- public root(
44
- state: RouterState,
45
- opts: { user?: UserAccountInfo } = {},
46
- ): ReactNode {
47
- return createElement(
48
- RouterContext.Provider,
49
- {
50
- value: {
51
- state,
52
- router: this,
53
- alepha: this.alepha,
54
- session: opts.user ? { user: opts.user } : undefined,
55
- },
56
- },
57
- state.layers[0]?.element,
58
- );
59
- }
60
-
61
- /**
62
- *
63
- * @param url
64
- * @param options
65
- */
66
- public async render(
67
- url: string,
68
- options: RouterRenderOptions = {},
69
- ): Promise<{
70
- element: ReactNode;
71
- layers: Layer[];
72
- redirect?: string;
73
- }> {
74
- const [pathname, search = ""] = url.split("?");
75
- const state: RouterState = {
76
- pathname,
77
- search,
78
- layers: [],
79
- };
80
-
81
- this.emit("begin", undefined);
82
-
83
- try {
84
- let layers = await this.match(url, options);
85
- if (layers.length === 0) {
86
- if (this.notFoundPageRoute) {
87
- layers = await this.createLayers(url, this.notFoundPageRoute);
88
- } else {
89
- layers.push({
90
- name: "not-found",
91
- element: "Not Found",
92
- index: 0,
93
- path: "/",
94
- });
95
- }
96
- }
97
-
98
- state.layers = layers;
99
- this.emit("success", undefined);
100
- } catch (e) {
101
- if (e instanceof RedirectException) {
102
- // redirect - stop processing
103
- return {
104
- element: null,
105
- layers: [],
106
- redirect: typeof e.page === "string" ? e.page : this.href(e.page),
107
- };
108
- }
109
-
110
- this.log.error(e);
111
-
112
- state.layers = [
113
- {
114
- name: "error",
115
- element: this.renderError(e as Error),
116
- index: 0,
117
- path: "/",
118
- },
119
- ];
120
-
121
- this.emit("error", e as Error);
122
- }
123
-
124
- if (options.state) {
125
- // stateful (csr)
126
- options.state.layers = state.layers;
127
- options.state.pathname = state.pathname;
128
- options.state.search = state.search;
129
-
130
- this.emit("end", options.state);
131
-
132
- return {
133
- element: this.root(options.state, options),
134
- layers: options.state.layers,
135
- };
136
- }
137
-
138
- // stateless (ssr)
139
- this.emit("end", state);
140
- return {
141
- element: this.root(state, options),
142
- layers: state.layers,
143
- };
144
- }
145
-
146
- /**
147
- *
148
- * @param url
149
- * @param options
150
- * @protected
151
- */
152
- public async match(
153
- url: string,
154
- options: RouterMatchOptions = {},
155
- ): Promise<Layer[]> {
156
- const pages = this.pages;
157
- const previous = options.previous;
158
-
159
- const [pathname, search] = url.split("?");
160
-
161
- for (const route of pages) {
162
- if (route.children?.find((it) => !it.path || it.path === "/")) continue;
163
- if (!route.match) continue;
164
-
165
- const match = route.match.exec(pathname);
166
- if (match) {
167
- const params = match.params ?? {};
168
- const query: Record<string, string> = {};
169
- if (search) {
170
- for (const [key, value] of new URLSearchParams(search).entries()) {
171
- query[key] = String(value);
172
- }
173
- }
174
-
175
- return await this.createLayers(
176
- url,
177
- route,
178
- params,
179
- query,
180
- previous,
181
- options.user,
182
- );
183
- }
184
- }
185
-
186
- return [];
187
- }
188
-
189
- /**
190
- * Create layers for the given route.
191
- *
192
- * @param route
193
- * @param params
194
- * @param query
195
- * @param previous
196
- * @param user
197
- * @protected
198
- */
199
- public async createLayers(
200
- url: string,
201
- route: PageRoute,
202
- params: Record<string, any> = {},
203
- query: Record<string, string> = {},
204
- previous: PreviousLayerData[] = [],
205
- user?: UserAccountInfo,
206
- ): Promise<Layer[]> {
207
- const layers: Layer[] = [];
208
- let context: Record<string, any> = {};
209
- const stack: Array<RouterStackItem> = [{ route }];
210
-
211
- let parent = route.parent;
212
- while (parent) {
213
- stack.unshift({ route: parent });
214
- parent = parent.parent;
215
- }
216
-
217
- let forceRefresh = false;
218
-
219
- for (let i = 0; i < stack.length; i++) {
220
- const it = stack[i];
221
- const route = it.route;
222
- const config: Record<string, any> = {};
223
-
224
- try {
225
- config.query = route.schema?.query
226
- ? this.alepha.parse(route.schema.query, query)
227
- : {};
228
- } catch (e) {
229
- it.error = e as Error;
230
- break;
231
- }
232
-
233
- try {
234
- config.params = route.schema?.params
235
- ? this.alepha.parse(route.schema.params, params)
236
- : {};
237
- } catch (e) {
238
- it.error = e as Error;
239
- break;
240
- }
241
-
242
- // save config
243
- it.config = {
244
- ...config,
245
- };
246
-
247
- // no resolve, render a basic view by default
248
- if (!route.resolve) {
249
- continue;
250
- }
251
-
252
- // check if previous layer is the same, reuse if possible
253
- if (previous?.[i] && !forceRefresh && previous[i].name === route.name) {
254
- const url = (str?: string) => (str ? str.replace(/\/\/+/g, "/") : "/");
255
-
256
- const prev = JSON.stringify({
257
- part: url(previous[i].part),
258
- params: previous[i].config?.params ?? {},
259
- });
260
-
261
- const curr = JSON.stringify({
262
- part: url(route.path),
263
- params: config.params ?? {},
264
- });
265
-
266
- if (prev === curr) {
267
- // part is the same, reuse previous layer
268
- it.props = previous[i].props;
269
- context = {
270
- ...context,
271
- ...it.props,
272
- };
273
- continue;
274
- }
275
- // part is different, force refresh of next layers
276
- forceRefresh = true;
277
- }
278
-
279
- try {
280
- const props =
281
- (await route.resolve?.({
282
- ...config,
283
- ...context,
284
- user,
285
- url,
286
- } as any)) ?? {};
287
-
288
- // save props
289
- it.props = {
290
- ...props,
291
- };
292
-
293
- // add props to context
294
- context = {
295
- ...context,
296
- ...props,
297
- };
298
- } catch (e) {
299
- // check if we need to redirect
300
- if (e instanceof RedirectException) {
301
- throw e; // redirect - stop processing
302
- }
303
-
304
- this.log.error(e);
305
-
306
- it.error = e as Error;
307
- break;
308
- }
309
- }
310
-
311
- let acc = "";
312
- for (let i = 0; i < stack.length; i++) {
313
- const it = stack[i];
314
- const props = it.props ?? {};
315
-
316
- const params = { ...it.config?.params };
317
- for (const key of Object.keys(params)) {
318
- params[key] = String(params[key]);
319
- }
320
-
321
- acc += "/";
322
- acc += it.route.path ? compile(it.route.path)(params) : "";
323
- const path = acc.replace(/\/+/, "/");
324
-
325
- // handler has thrown an error, render an error view
326
- if (it.error) {
327
- const errorHandler = this.getErrorHandler(it.route);
328
- const element = errorHandler
329
- ? errorHandler({
330
- ...it.config,
331
- error: it.error,
332
- url,
333
- })
334
- : this.renderError(it.error);
335
-
336
- layers.push({
337
- props,
338
- name: it.route.name,
339
- part: it.route.path,
340
- config: it.config,
341
- element: this.renderView(i + 1, path, element),
342
- index: i + 1,
343
- path,
344
- });
345
- break;
346
- }
347
-
348
- // normal use case
349
-
350
- const layer = await this.createElement(it.route, {
351
- ...props,
352
- ...context,
353
- });
354
-
355
- layers.push({
356
- name: it.route.name,
357
- props,
358
- part: it.route.path,
359
- config: it.config,
360
- element: this.renderView(i + 1, path, layer),
361
- index: i + 1,
362
- path,
363
- });
364
- }
365
-
366
- return layers;
367
- }
368
-
369
- /**
370
- *
371
- * @param route
372
- * @protected
373
- */
374
- protected getErrorHandler(route: PageRoute) {
375
- if (route.errorHandler) return route.errorHandler;
376
- let parent = route.parent;
377
- while (parent) {
378
- if (parent.errorHandler) return parent.errorHandler;
379
- parent = parent.parent;
380
- }
381
- }
382
-
383
- /**
384
- *
385
- * @param page
386
- * @param props
387
- * @protected
388
- */
389
- protected async createElement(
390
- page: PageRoute,
391
- props: Record<string, any>,
392
- ): Promise<ReactNode> {
393
- if (page.lazy) {
394
- const component = await page.lazy(); // load component
395
- return createElement(component.default, props);
396
- }
397
-
398
- if (page.component) {
399
- return createElement(page.component, props);
400
- }
401
-
402
- return undefined;
403
- }
404
-
405
- /**
406
- *
407
- * @param e
408
- * @protected
409
- */
410
- protected renderError(e: Error): ReactNode {
411
- return createElement("pre", { style: { overflow: "auto" } }, `${e.stack}`);
412
- }
413
-
414
- /**
415
- * Render an empty view.
416
- *
417
- * @protected
418
- */
419
- protected renderEmptyView(): ReactNode {
420
- return createElement(NestedView, {});
421
- }
422
-
423
- /**
424
- * Create a valid href for the given page.
425
- * @param page
426
- * @param params
427
- */
428
- public href(
429
- page: { options: { name?: string } },
430
- params: Record<string, any> = {},
431
- ): string {
432
- const found = this.pages.find((it) => it.name === page.options.name);
433
- if (!found) {
434
- throw new Error(`Page ${page.options.name} not found`);
435
- }
436
-
437
- let url = found.path ?? "";
438
- let parent = found.parent;
439
- while (parent) {
440
- url = `${parent.path ?? ""}/${url}`;
441
- parent = parent.parent;
442
- }
443
-
444
- url = compile(url)(params);
445
-
446
- return url.replace(/\/\/+/g, "/") || "/";
447
- }
448
-
449
- /**
450
- *
451
- * @param index
452
- * @param path
453
- * @param view
454
- * @protected
455
- */
456
- protected renderView(
457
- index: number,
458
- path: string,
459
- view: ReactNode = this.renderEmptyView(),
460
- ): ReactNode {
461
- return createElement(
462
- RouterLayerContext.Provider,
463
- {
464
- value: {
465
- index,
466
- path,
467
- },
468
- },
469
- view,
470
- );
471
- }
472
-
473
- /**
474
- *
475
- * @param entry
476
- */
477
- public add(entry: PageRouteEntry) {
478
- if (this.alepha.isReady()) {
479
- throw new Error("Router is already initialized");
480
- }
481
-
482
- if (entry.notFoundHandler) {
483
- this.notFoundPageRoute = {
484
- name: "not-found",
485
- component: entry.notFoundHandler,
486
- };
487
- }
488
-
489
- entry.name ??= this.nextId();
490
- const page = entry as PageRoute;
491
-
492
- page.match = this.createMatchFunction(page);
493
- this.pages.push(page);
494
-
495
- if (page.children) {
496
- for (const child of page.children) {
497
- child.parent = page;
498
- this.add(child);
499
- }
500
- }
501
- }
502
-
503
- /**
504
- * Create a match function for the given page.
505
- *
506
- * @param page
507
- * @protected
508
- */
509
- protected createMatchFunction(
510
- page: PageRoute,
511
- ): { exec: MatchFunction<ParamData>; path: string } | undefined {
512
- let url = page.path ?? "/";
513
- let target = page.parent;
514
- while (target) {
515
- url = `${target.path ?? ""}/${url}`;
516
- target = target.parent;
517
- }
518
-
519
- let path = url.replace(/\/\/+/g, "/");
520
-
521
- if (path.endsWith("/")) {
522
- // remove trailing slash
523
- path = path.slice(0, -1);
524
- }
525
-
526
- if (path.includes("?")) {
527
- return {
528
- exec: match(path.split("?")[0]),
529
- path,
530
- };
531
- }
532
-
533
- return {
534
- exec: match(path),
535
- path,
536
- };
537
- }
538
-
539
- /**
540
- *
541
- */
542
- public empty() {
543
- return this.pages.length === 0;
544
- }
545
-
546
- /**
547
- *
548
- * @protected
549
- */
550
- protected _next = 0;
551
-
552
- /**
553
- *
554
- * @protected
555
- */
556
- protected nextId(): string {
557
- this._next += 1;
558
- return `P${this._next}`;
559
- }
560
- }
561
-
562
- // ---------------------------------------------------------------------------------------------------------------------
563
-
564
- export interface PageRouteEntry
565
- extends Omit<PageDescriptorOptions, "children" | "parent"> {
566
- /**
567
- *
568
- */
569
- name?: string;
570
-
571
- /**
572
- *
573
- */
574
- match?: {
575
- /**
576
- *
577
- */
578
- exec: MatchFunction<ParamData>;
579
-
580
- /**
581
- *
582
- */
583
- path: string;
584
- };
585
-
586
- /**
587
- *
588
- */
589
- children?: PageRouteEntry[];
590
-
591
- /**
592
- *
593
- */
594
- parent?: PageRoute;
595
- }
596
-
597
- export interface PageRoute extends PageRouteEntry {
598
- /**
599
- *
600
- */
601
- name: string;
602
-
603
- /**
604
- *
605
- */
606
- parent?: PageRoute;
607
- }
608
-
609
- export interface Layer {
610
- /**
611
- *
612
- */
613
- config?: {
614
- /**
615
- *
616
- */
617
- query?: Record<string, any>;
618
-
619
- /**
620
- *
621
- */
622
- params?: Record<string, any>;
623
-
624
- /**
625
- *
626
- */
627
- context?: Record<string, any>;
628
- };
629
-
630
- /**
631
- *
632
- */
633
- name: string;
634
-
635
- /**
636
- *
637
- */
638
- props?: Record<string, any>;
639
-
640
- /**
641
- *
642
- */
643
- part?: string;
644
-
645
- /**
646
- *
647
- */
648
- element: ReactNode;
649
-
650
- /**
651
- *
652
- */
653
- index: number;
654
-
655
- /**
656
- *
657
- */
658
- path: string;
659
- }
660
-
661
- /**
662
- *
663
- */
664
- export type PreviousLayerData = Omit<Layer, "element">;
665
-
666
- export interface AnchorProps {
667
- /**
668
- *
669
- */
670
- href?: string;
671
-
672
- /**
673
- *
674
- * @param ev
675
- */
676
- onClick?: (ev: any) => any;
677
- }
678
-
679
- export interface RouterMatchOptions {
680
- /**
681
- *
682
- */
683
- previous?: PreviousLayerData[];
684
-
685
- /**
686
- *
687
- */
688
- user?: UserAccountInfo;
689
- }
690
-
691
- export interface RouterEvents {
692
- /**
693
- *
694
- */
695
- begin: undefined;
696
-
697
- /**
698
- *
699
- */
700
- success: undefined;
701
-
702
- /**
703
- *
704
- */
705
- error: Error;
706
-
707
- /**
708
- *
709
- */
710
- end: RouterState;
711
- }
712
-
713
- export interface RouterState {
714
- /**
715
- *
716
- */
717
- pathname: string;
718
-
719
- /**
720
- *
721
- */
722
- search: string;
723
-
724
- /**
725
- *
726
- */
727
- layers: Array<Layer>;
728
- }
729
-
730
- export interface RouterRenderOptions extends RouterMatchOptions {
731
- /**
732
- * State to update.
733
- */
734
- state?: RouterState;
735
- }
736
-
737
- export interface RouterStackItem {
738
- route: PageRoute;
739
- config?: Record<string, any>;
740
- props?: Record<string, any>;
741
- error?: Error;
742
- }