@alepha/react 0.6.0 → 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,855 +0,0 @@
1
- import { $inject, $logger, Alepha, EventEmitter } from "@alepha/core";
2
- import type { MatchFunction, ParamData } from "path-to-regexp";
3
- import { compile, match } from "path-to-regexp";
4
- import type { ReactNode } from "react";
5
- import { createElement } from "react";
6
- import NestedView from "../components/NestedView";
7
- import { RouterContext } from "../contexts/RouterContext";
8
- import { RouterLayerContext } from "../contexts/RouterLayerContext";
9
- import type { PageContext, PageDescriptorOptions } from "../descriptors/$page";
10
- import { RedirectionError } from "../errors/RedirectionError";
11
-
12
- export class Router extends EventEmitter<RouterEvents> {
13
- protected readonly log = $logger();
14
- protected readonly alepha = $inject(Alepha);
15
- protected readonly pages: PageRoute[] = [];
16
- protected notFoundPageRoute?: PageRoute;
17
-
18
- /**
19
- * Get the page by name.
20
- *
21
- * @param name - Page name
22
- * @return PageRoute
23
- */
24
- public page(name: string): PageRoute {
25
- const found = this.pages.find((it) => it.name === name);
26
- if (!found) {
27
- throw new Error(`Page ${name} not found`);
28
- }
29
-
30
- return found;
31
- }
32
-
33
- /**
34
- *
35
- */
36
- public root(state: RouterState, context: PageContext = {}): ReactNode {
37
- return createElement(
38
- RouterContext.Provider,
39
- {
40
- value: {
41
- state,
42
- router: this,
43
- alepha: this.alepha,
44
- args: {
45
- user: context.user,
46
- cookies: context.cookies,
47
- },
48
- },
49
- },
50
- state.layers[0]?.element,
51
- );
52
- }
53
-
54
- /**
55
- *
56
- * @param url
57
- * @param options
58
- */
59
- public async render(
60
- url: string,
61
- options: RouterRenderOptions = {},
62
- ): Promise<RouterRenderResult> {
63
- const [pathname, search = ""] = url.split("?");
64
- const state: RouterState = {
65
- pathname,
66
- search,
67
- layers: [],
68
- context: {},
69
- };
70
-
71
- await this.emit("begin", undefined);
72
-
73
- try {
74
- let layers = await this.match(url, options, state.context);
75
- if (layers.length === 0) {
76
- if (this.notFoundPageRoute) {
77
- layers = await this.createLayers(url, this.notFoundPageRoute);
78
- } else {
79
- layers.push({
80
- name: "not-found",
81
- element: "Not Found",
82
- index: 0,
83
- path: "/",
84
- });
85
- }
86
- }
87
-
88
- state.layers = layers;
89
- await this.emit("success", undefined);
90
- } catch (e) {
91
- if (e instanceof RedirectionError) {
92
- // redirect - stop processing
93
- return {
94
- element: null,
95
- layers: [],
96
- redirect: typeof e.page === "string" ? e.page : this.href(e.page),
97
- context: state.context,
98
- };
99
- }
100
-
101
- this.log.error(e);
102
-
103
- state.layers = [
104
- {
105
- name: "error",
106
- element: this.renderError(e as Error),
107
- index: 0,
108
- path: "/",
109
- },
110
- ];
111
-
112
- await this.emit("error", e as Error);
113
- }
114
-
115
- if (options.state) {
116
- // stateful (csr)
117
- options.state.layers = state.layers;
118
- options.state.pathname = state.pathname;
119
- options.state.search = state.search;
120
- options.state.context = state.context;
121
-
122
- await this.emit("end", options.state);
123
-
124
- return {
125
- element: this.root(options.state, options.args),
126
- layers: options.state.layers,
127
- context: state.context,
128
- };
129
- }
130
-
131
- // stateless (ssr)
132
- await this.emit("end", state);
133
- return {
134
- element: this.root(state, options.args),
135
- layers: state.layers,
136
- context: state.context,
137
- };
138
- }
139
-
140
- /**
141
- *
142
- * @param url
143
- * @param options
144
- * @param context
145
- * @protected
146
- */
147
- public async match(
148
- url: string,
149
- options: RouterMatchOptions = {},
150
- context: RouterRenderContext = {},
151
- ): Promise<Layer[]> {
152
- const pages = this.pages;
153
- const previous = options.previous;
154
-
155
- const [pathname, search] = url.split("?");
156
-
157
- for (const route of pages) {
158
- if (route.children?.find((it) => !it.path || it.path === "/")) continue;
159
- if (!route.match) continue;
160
-
161
- const match = route.match.exec(pathname);
162
- if (match) {
163
- const params = match.params ?? {};
164
- const query: Record<string, string> = {};
165
- if (search) {
166
- for (const [key, value] of new URLSearchParams(search).entries()) {
167
- query[key] = String(value);
168
- }
169
- }
170
-
171
- return await this.createLayers(
172
- url,
173
- route,
174
- params,
175
- query,
176
- previous,
177
- options.args,
178
- context,
179
- );
180
- }
181
- }
182
-
183
- return [];
184
- }
185
-
186
- /**
187
- * Create layers for the given route.
188
- *
189
- * @param url
190
- * @param route
191
- * @param params
192
- * @param query
193
- * @param previous
194
- * @param args
195
- * @param renderContext
196
- * @protected
197
- */
198
- public async createLayers(
199
- url: string,
200
- route: PageRoute,
201
- params: Record<string, any> = {},
202
- query: Record<string, string> = {},
203
- previous: PreviousLayerData[] = [],
204
- args?: PageContext,
205
- renderContext?: RouterRenderContext,
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
- : query;
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
- : params;
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
- {
283
- ...config,
284
- ...context,
285
- context: args,
286
- url,
287
- } as any,
288
- args ?? {},
289
- )) ?? {};
290
-
291
- // save props
292
- it.props = {
293
- ...props,
294
- };
295
-
296
- // add props to context
297
- context = {
298
- ...context,
299
- ...props,
300
- };
301
- } catch (e) {
302
- // check if we need to redirect
303
- if (e instanceof RedirectionError) {
304
- throw e; // redirect - stop processing
305
- }
306
-
307
- this.log.error(e);
308
-
309
- it.error = e as Error;
310
- break;
311
- }
312
- }
313
-
314
- let acc = "";
315
- for (let i = 0; i < stack.length; i++) {
316
- const it = stack[i];
317
- const props = it.props ?? {};
318
-
319
- const params = { ...it.config?.params };
320
- for (const key of Object.keys(params)) {
321
- params[key] = String(params[key]);
322
- }
323
-
324
- if (it.route.helmet && renderContext) {
325
- this.mergeRenderContext(it.route, renderContext, {
326
- ...props,
327
- ...context,
328
- });
329
- }
330
-
331
- acc += "/";
332
- acc += it.route.path ? compile(it.route.path)(params) : "";
333
- const path = acc.replace(/\/+/, "/");
334
-
335
- // handler has thrown an error, render an error view
336
- if (it.error) {
337
- const errorHandler = this.getErrorHandler(it.route);
338
- const element = errorHandler
339
- ? errorHandler({
340
- ...it.config,
341
- error: it.error,
342
- url,
343
- })
344
- : this.renderError(it.error);
345
-
346
- layers.push({
347
- props,
348
- name: it.route.name,
349
- part: it.route.path,
350
- config: it.config,
351
- element: this.renderView(i + 1, path, element),
352
- index: i + 1,
353
- path,
354
- });
355
- break;
356
- }
357
-
358
- // normal use case
359
-
360
- const layer = await this.createElement(it.route, {
361
- ...props,
362
- ...context,
363
- });
364
-
365
- layers.push({
366
- name: it.route.name,
367
- props,
368
- part: it.route.path,
369
- config: it.config,
370
- element: this.renderView(i + 1, path, layer),
371
- index: i + 1,
372
- path,
373
- });
374
- }
375
-
376
- return layers;
377
- }
378
-
379
- /**
380
- *
381
- * @param route
382
- * @protected
383
- */
384
- protected getErrorHandler(route: PageRoute) {
385
- if (route.errorHandler) return route.errorHandler;
386
- let parent = route.parent;
387
- while (parent) {
388
- if (parent.errorHandler) return parent.errorHandler;
389
- parent = parent.parent;
390
- }
391
- }
392
-
393
- /**
394
- *
395
- * @param page
396
- * @param props
397
- * @protected
398
- */
399
- protected async createElement(
400
- page: PageRoute,
401
- props: Record<string, any>,
402
- ): Promise<ReactNode> {
403
- if (page.lazy) {
404
- const component = await page.lazy(); // load component
405
- return createElement(component.default, props);
406
- }
407
-
408
- if (page.component) {
409
- return createElement(page.component, props);
410
- }
411
-
412
- return undefined;
413
- }
414
-
415
- /**
416
- * Merge the render context with the page context.
417
- *
418
- * @param page
419
- * @param ctx
420
- * @param props
421
- * @protected
422
- */
423
- protected mergeRenderContext(
424
- page: PageRoute,
425
- ctx: RouterRenderContext,
426
- props: Record<string, any>,
427
- ): void {
428
- if (page.helmet) {
429
- const helmet =
430
- typeof page.helmet === "function" ? page.helmet(props) : page.helmet;
431
- if (helmet.title) {
432
- ctx.helmet ??= {};
433
-
434
- if (ctx.helmet?.title) {
435
- ctx.helmet.title = `${helmet.title} - ${ctx.helmet.title}`;
436
- } else {
437
- ctx.helmet.title = helmet.title;
438
- }
439
- }
440
- }
441
- }
442
-
443
- /**
444
- *
445
- * @param e
446
- * @protected
447
- */
448
- protected renderError(e: Error): ReactNode {
449
- return createElement("pre", { style: { overflow: "auto" } }, `${e.stack}`);
450
- }
451
-
452
- /**
453
- * Render an empty view.
454
- *
455
- * @protected
456
- */
457
- protected renderEmptyView(): ReactNode {
458
- return createElement(NestedView, {});
459
- }
460
-
461
- /**
462
- * Create a valid href for the given page.
463
- * @param page
464
- * @param params
465
- */
466
- public href(
467
- page: { options: { name?: string } },
468
- params: Record<string, any> = {},
469
- ): string {
470
- const found = this.pages.find((it) => it.name === page.options.name);
471
- if (!found) {
472
- throw new Error(`Page ${page.options.name} not found`);
473
- }
474
-
475
- let url = found.path ?? "";
476
- let parent = found.parent;
477
- while (parent) {
478
- url = `${parent.path ?? ""}/${url}`;
479
- parent = parent.parent;
480
- }
481
-
482
- url = compile(url)(params);
483
-
484
- return url.replace(/\/\/+/g, "/") || "/";
485
- }
486
-
487
- /**
488
- *
489
- * @param index
490
- * @param path
491
- * @param view
492
- * @protected
493
- */
494
- protected renderView(
495
- index: number,
496
- path: string,
497
- view: ReactNode = this.renderEmptyView(),
498
- ): ReactNode {
499
- return createElement(
500
- RouterLayerContext.Provider,
501
- {
502
- value: {
503
- index,
504
- path,
505
- },
506
- },
507
- view,
508
- );
509
- }
510
-
511
- /**
512
- *
513
- * @param entry
514
- */
515
- public add(entry: PageRouteEntry) {
516
- if (this.alepha.isReady()) {
517
- throw new Error("Router is already initialized");
518
- }
519
-
520
- if (entry.notFoundHandler) {
521
- this.notFoundPageRoute = {
522
- name: "not-found",
523
- component: entry.notFoundHandler,
524
- };
525
- }
526
-
527
- entry.name ??= this.nextId();
528
- const page = entry as PageRoute;
529
-
530
- page.match = this.createMatchFunction(page);
531
- this.pages.push(page);
532
-
533
- if (page.children) {
534
- for (const child of page.children) {
535
- child.parent = page;
536
- this.add(child);
537
- }
538
- }
539
- }
540
-
541
- /**
542
- * Create a match function for the given page.
543
- *
544
- * @param page
545
- * @protected
546
- */
547
- protected createMatchFunction(
548
- page: PageRoute,
549
- ): { exec: MatchFunction<ParamData>; path: string } | undefined {
550
- let url = page.path ?? "/";
551
- let target = page.parent;
552
- while (target) {
553
- url = `${target.path ?? ""}/${url}`;
554
- target = target.parent;
555
- }
556
-
557
- let path = url.replace(/\/\/+/g, "/");
558
-
559
- if (path.endsWith("/")) {
560
- // remove trailing slash
561
- path = path.slice(0, -1);
562
- }
563
-
564
- if (path.includes("?")) {
565
- return {
566
- exec: match(path.split("?")[0]),
567
- path,
568
- };
569
- }
570
-
571
- return {
572
- exec: match(path),
573
- path,
574
- };
575
- }
576
-
577
- /**
578
- *
579
- */
580
- public empty() {
581
- return this.pages.length === 0;
582
- }
583
-
584
- /**
585
- *
586
- * @protected
587
- */
588
- protected _next = 0;
589
-
590
- /**
591
- *
592
- * @protected
593
- */
594
- protected nextId(): string {
595
- this._next += 1;
596
- return `P${this._next}`;
597
- }
598
- }
599
-
600
- // ---------------------------------------------------------------------------------------------------------------------
601
-
602
- export interface PageRouteEntry
603
- extends Omit<PageDescriptorOptions, "children" | "parent"> {
604
- /**
605
- *
606
- */
607
- name?: string;
608
-
609
- /**
610
- *
611
- */
612
- match?: {
613
- /**
614
- *
615
- */
616
- exec: MatchFunction<ParamData>;
617
-
618
- /**
619
- *
620
- */
621
- path: string;
622
- };
623
-
624
- /**
625
- *
626
- */
627
- children?: PageRouteEntry[];
628
-
629
- /**
630
- *
631
- */
632
- parent?: PageRoute;
633
- }
634
-
635
- export interface PageRoute extends PageRouteEntry {
636
- /**
637
- *
638
- */
639
- name: string;
640
-
641
- /**
642
- *
643
- */
644
- parent?: PageRoute;
645
- }
646
-
647
- export interface Layer {
648
- /**
649
- *
650
- */
651
- config?: {
652
- /**
653
- *
654
- */
655
- query?: Record<string, any>;
656
-
657
- /**
658
- *
659
- */
660
- params?: Record<string, any>;
661
-
662
- /**
663
- *
664
- */
665
- context?: Record<string, any>;
666
- };
667
-
668
- /**
669
- *
670
- */
671
- name: string;
672
-
673
- /**
674
- *
675
- */
676
- props?: Record<string, any>;
677
-
678
- /**
679
- *
680
- */
681
- part?: string;
682
-
683
- /**
684
- *
685
- */
686
- element: ReactNode;
687
-
688
- /**
689
- *
690
- */
691
- index: number;
692
-
693
- /**
694
- *
695
- */
696
- path: string;
697
- }
698
-
699
- /**
700
- *
701
- */
702
- export type PreviousLayerData = Omit<Layer, "element">;
703
-
704
- export interface AnchorProps {
705
- /**
706
- *
707
- */
708
- href?: string;
709
-
710
- /**
711
- *
712
- * @param ev
713
- */
714
- onClick?: (ev: any) => any;
715
- }
716
-
717
- export interface RouterMatchOptions {
718
- /**
719
- *
720
- */
721
- previous?: PreviousLayerData[];
722
-
723
- /**
724
- *
725
- */
726
- args?: PageContext;
727
- }
728
-
729
- export interface RouterEvents {
730
- /**
731
- *
732
- */
733
- begin: undefined;
734
-
735
- /**
736
- *
737
- */
738
- success: undefined;
739
-
740
- /**
741
- *
742
- */
743
- error: Error;
744
-
745
- /**
746
- *
747
- */
748
- end: RouterState;
749
- }
750
-
751
- export interface RouterState {
752
- /**
753
- *
754
- */
755
- pathname: string;
756
-
757
- /**
758
- *
759
- */
760
- search: string;
761
-
762
- /**
763
- *
764
- */
765
- layers: Array<Layer>;
766
-
767
- /**
768
- *
769
- */
770
- context: RouterRenderContext;
771
- }
772
-
773
- export interface RouterRenderContext {
774
- /**
775
- *
776
- */
777
- helmet?: RouterRenderHelmetContext;
778
- }
779
-
780
- export interface RouterRenderOptions extends RouterMatchOptions {
781
- /**
782
- * State to update.
783
- */
784
- state?: RouterState;
785
- }
786
-
787
- export interface RouterStackItem {
788
- /**
789
- *
790
- */
791
- route: PageRoute;
792
-
793
- /**
794
- *
795
- */
796
- config?: Record<string, any>;
797
-
798
- /**
799
- *
800
- */
801
- props?: Record<string, any>;
802
-
803
- /**
804
- *
805
- */
806
- error?: Error;
807
- }
808
-
809
- export interface RouterRenderHelmetContext {
810
- /**
811
- *
812
- */
813
- title?: string;
814
-
815
- /**
816
- *
817
- */
818
- html?: {
819
- attributes?: Record<string, string>;
820
- };
821
-
822
- /**
823
- *
824
- */
825
- body?: {
826
- attributes?: Record<string, string>;
827
- };
828
-
829
- /**
830
- *
831
- */
832
- meta?: Array<{ name: string; content: string }>;
833
- }
834
-
835
- export interface RouterRenderResult {
836
- /**
837
- *
838
- */
839
- element: ReactNode;
840
-
841
- /**
842
- *
843
- */
844
- layers: Layer[];
845
-
846
- /**
847
- *
848
- */
849
- redirect?: string;
850
-
851
- /**
852
- *
853
- */
854
- context: RouterRenderContext;
855
- }