@alepha/react 0.10.6 → 0.10.7

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,11 +1,12 @@
1
1
  import {
2
- $env,
3
- $hook,
4
- $inject,
5
- Alepha,
6
- type Static,
7
- type TSchema,
8
- t,
2
+ $env,
3
+ $hook,
4
+ $inject,
5
+ Alepha,
6
+ AlephaError,
7
+ type Static,
8
+ type TSchema,
9
+ t,
9
10
  } from "@alepha/core";
10
11
  import { $logger } from "@alepha/logger";
11
12
  import { createElement, type ReactNode, StrictMode } from "react";
@@ -16,651 +17,651 @@ import NotFoundPage from "../components/NotFound.tsx";
16
17
  import { AlephaContext } from "../contexts/AlephaContext.ts";
17
18
  import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
18
19
  import {
19
- $page,
20
- type ErrorHandler,
21
- type PageDescriptor,
22
- type PageDescriptorOptions,
20
+ $page,
21
+ type ErrorHandler,
22
+ type PageDescriptor,
23
+ type PageDescriptorOptions,
23
24
  } from "../descriptors/$page.ts";
24
25
  import { Redirection } from "../errors/Redirection.ts";
25
26
 
26
27
  const envSchema = t.object({
27
- REACT_STRICT_MODE: t.boolean({ default: true }),
28
+ REACT_STRICT_MODE: t.boolean({ default: true }),
28
29
  });
29
30
 
30
31
  declare module "@alepha/core" {
31
- export interface Env extends Partial<Static<typeof envSchema>> {}
32
+ export interface Env extends Partial<Static<typeof envSchema>> {}
32
33
  }
33
34
 
34
35
  export class ReactPageProvider {
35
- protected readonly log = $logger();
36
- protected readonly env = $env(envSchema);
37
- protected readonly alepha = $inject(Alepha);
38
- protected readonly pages: PageRoute[] = [];
39
-
40
- public getPages(): PageRoute[] {
41
- return this.pages;
42
- }
43
-
44
- public page(name: string): PageRoute {
45
- for (const page of this.pages) {
46
- if (page.name === name) {
47
- return page;
48
- }
49
- }
50
-
51
- throw new Error(`Page ${name} not found`);
52
- }
53
-
54
- public pathname(
55
- name: string,
56
- options: {
57
- params?: Record<string, string>;
58
- query?: Record<string, string>;
59
- } = {},
60
- ) {
61
- const page = this.page(name);
62
- if (!page) {
63
- throw new Error(`Page ${name} not found`);
64
- }
65
-
66
- let url = page.path ?? "";
67
- let parent = page.parent;
68
- while (parent) {
69
- url = `${parent.path ?? ""}/${url}`;
70
- parent = parent.parent;
71
- }
72
-
73
- url = this.compile(url, options.params ?? {});
74
-
75
- if (options.query) {
76
- const query = new URLSearchParams(options.query);
77
- if (query.toString()) {
78
- url += `?${query.toString()}`;
79
- }
80
- }
81
-
82
- return url.replace(/\/\/+/g, "/") || "/";
83
- }
84
-
85
- public url(
86
- name: string,
87
- options: { params?: Record<string, string>; host?: string } = {},
88
- ): URL {
89
- return new URL(
90
- this.pathname(name, options),
91
- // use provided base or default to http://localhost
92
- options.host ?? `http://localhost`,
93
- );
94
- }
95
-
96
- public root(state: ReactRouterState): ReactNode {
97
- const root = createElement(
98
- AlephaContext.Provider,
99
- { value: this.alepha },
100
- createElement(NestedView, {}, state.layers[0]?.element),
101
- );
102
-
103
- if (this.env.REACT_STRICT_MODE) {
104
- return createElement(StrictMode, {}, root);
105
- }
106
-
107
- return root;
108
- }
109
-
110
- protected convertStringObjectToObject = (
111
- schema?: TSchema,
112
- value?: any,
113
- ): any => {
114
- if (t.schema.isObject(schema) && typeof value === "object") {
115
- for (const key in schema.properties) {
116
- if (
117
- t.schema.isObject(schema.properties[key]) &&
118
- typeof value[key] === "string"
119
- ) {
120
- try {
121
- value[key] = this.alepha.parse(
122
- schema.properties[key],
123
- decodeURIComponent(value[key]),
124
- );
125
- } catch (e) {
126
- // ignore
127
- }
128
- }
129
- }
130
- }
131
- return value;
132
- };
133
-
134
- /**
135
- * Create a new RouterState based on a given route and request.
136
- * This method resolves the layers for the route, applying any query and params schemas defined in the route.
137
- * It also handles errors and redirects.
138
- */
139
- public async createLayers(
140
- route: PageRoute,
141
- state: ReactRouterState,
142
- previous: PreviousLayerData[] = [],
143
- ): Promise<CreateLayersResult> {
144
- let context: Record<string, any> = {}; // all props
145
- const stack: Array<RouterStackItem> = [{ route }]; // stack of routes
146
-
147
- let parent = route.parent;
148
- while (parent) {
149
- stack.unshift({ route: parent });
150
- parent = parent.parent;
151
- }
152
-
153
- let forceRefresh = false;
154
-
155
- for (let i = 0; i < stack.length; i++) {
156
- const it = stack[i];
157
- const route = it.route;
158
- const config: Record<string, any> = {};
159
-
160
- try {
161
- this.convertStringObjectToObject(route.schema?.query, state.query);
162
- config.query = route.schema?.query
163
- ? this.alepha.parse(route.schema.query, state.query)
164
- : {};
165
- } catch (e) {
166
- it.error = e as Error;
167
- break;
168
- }
169
-
170
- try {
171
- config.params = route.schema?.params
172
- ? this.alepha.parse(route.schema.params, state.params)
173
- : {};
174
- } catch (e) {
175
- it.error = e as Error;
176
- break;
177
- }
178
-
179
- // save config
180
- it.config = {
181
- ...config,
182
- };
183
-
184
- // check if previous layer is the same, reuse if possible
185
- if (previous?.[i] && !forceRefresh && previous[i].name === route.name) {
186
- const url = (str?: string) => (str ? str.replace(/\/\/+/g, "/") : "/");
187
-
188
- const prev = JSON.stringify({
189
- part: url(previous[i].part),
190
- params: previous[i].config?.params ?? {},
191
- });
192
-
193
- const curr = JSON.stringify({
194
- part: url(route.path),
195
- params: config.params ?? {},
196
- });
197
-
198
- if (prev === curr) {
199
- // part is the same, reuse previous layer
200
- it.props = previous[i].props;
201
- it.error = previous[i].error;
202
- it.cache = true;
203
- context = {
204
- ...context,
205
- ...it.props,
206
- };
207
- continue;
208
- }
209
-
210
- // part is different, force refresh of next layers
211
- forceRefresh = true;
212
- }
213
-
214
- // no resolve, render a basic view by default
215
- if (!route.resolve) {
216
- continue;
217
- }
218
-
219
- try {
220
- const args = Object.create(state);
221
- Object.assign(args, config, context);
222
- const props = (await route.resolve?.(args)) ?? {};
223
-
224
- // save props
225
- it.props = {
226
- ...props,
227
- };
228
-
229
- // add props to context
230
- context = {
231
- ...context,
232
- ...props,
233
- };
234
- } catch (e) {
235
- // check if we need to redirect
236
- if (e instanceof Redirection) {
237
- return {
238
- redirect: e.redirect,
239
- };
240
- }
241
-
242
- this.log.error("Page resolver has failed", e);
243
-
244
- it.error = e as Error;
245
- break;
246
- }
247
- }
248
-
249
- let acc = "";
250
- for (let i = 0; i < stack.length; i++) {
251
- const it = stack[i];
252
- const props = it.props ?? {};
253
-
254
- const params = { ...it.config?.params };
255
- for (const key of Object.keys(params)) {
256
- params[key] = String(params[key]);
257
- }
258
-
259
- acc += "/";
260
- acc += it.route.path ? this.compile(it.route.path, params) : "";
261
- const path = acc.replace(/\/+/, "/");
262
- const localErrorHandler = this.getErrorHandler(it.route);
263
- if (localErrorHandler) {
264
- const onErrorParent = state.onError;
265
- state.onError = (error, context) => {
266
- const result = localErrorHandler(error, context);
267
- // if nothing happen, call the parent
268
- if (result === undefined) {
269
- return onErrorParent(error, context);
270
- }
271
- return result;
272
- };
273
- }
274
-
275
- // normal use case
276
- if (!it.error) {
277
- try {
278
- const element = await this.createElement(it.route, {
279
- ...props,
280
- ...context,
281
- });
282
-
283
- state.layers.push({
284
- name: it.route.name,
285
- props,
286
- part: it.route.path,
287
- config: it.config,
288
- element: this.renderView(i + 1, path, element, it.route),
289
- index: i + 1,
290
- path,
291
- route: it.route,
292
- cache: it.cache,
293
- });
294
- } catch (e) {
295
- it.error = e as Error;
296
- }
297
- }
298
-
299
- // handler has thrown an error, render an error view
300
- if (it.error) {
301
- try {
302
- let element: ReactNode | Redirection | undefined =
303
- await state.onError(it.error, state);
304
-
305
- if (element === undefined) {
306
- throw it.error;
307
- }
308
-
309
- if (element instanceof Redirection) {
310
- return {
311
- redirect: element.redirect,
312
- };
313
- }
314
-
315
- if (element === null) {
316
- element = this.renderError(it.error);
317
- }
318
-
319
- state.layers.push({
320
- props,
321
- error: it.error,
322
- name: it.route.name,
323
- part: it.route.path,
324
- config: it.config,
325
- element: this.renderView(i + 1, path, element, it.route),
326
- index: i + 1,
327
- path,
328
- route: it.route,
329
- });
330
- break;
331
- } catch (e) {
332
- if (e instanceof Redirection) {
333
- return {
334
- redirect: e.redirect,
335
- };
336
- }
337
- throw e;
338
- }
339
- }
340
- }
341
-
342
- return { state };
343
- }
344
-
345
- protected createRedirectionLayer(redirect: string): CreateLayersResult {
346
- return {
347
- redirect,
348
- };
349
- }
350
-
351
- protected getErrorHandler(route: PageRoute): ErrorHandler | undefined {
352
- if (route.errorHandler) return route.errorHandler;
353
- let parent = route.parent;
354
- while (parent) {
355
- if (parent.errorHandler) return parent.errorHandler;
356
- parent = parent.parent;
357
- }
358
- }
359
-
360
- protected async createElement(
361
- page: PageRoute,
362
- props: Record<string, any>,
363
- ): Promise<ReactNode> {
364
- if (page.lazy && page.component) {
365
- this.log.warn(
366
- `Page ${page.name} has both lazy and component options, lazy will be used`,
367
- );
368
- }
369
-
370
- if (page.lazy) {
371
- const component = await page.lazy(); // load component
372
- return createElement(component.default, props);
373
- }
374
-
375
- if (page.component) {
376
- return createElement(page.component, props);
377
- }
378
-
379
- return undefined;
380
- }
381
-
382
- public renderError(error: Error): ReactNode {
383
- return createElement(ErrorViewer, { error, alepha: this.alepha });
384
- }
385
-
386
- public renderEmptyView(): ReactNode {
387
- return createElement(NestedView, {});
388
- }
389
-
390
- public href(
391
- page: { options: { name?: string } },
392
- params: Record<string, any> = {},
393
- ): string {
394
- const found = this.pages.find((it) => it.name === page.options.name);
395
- if (!found) {
396
- throw new Error(`Page ${page.options.name} not found`);
397
- }
398
-
399
- let url = found.path ?? "";
400
- let parent = found.parent;
401
- while (parent) {
402
- url = `${parent.path ?? ""}/${url}`;
403
- parent = parent.parent;
404
- }
405
-
406
- url = this.compile(url, params);
407
-
408
- return url.replace(/\/\/+/g, "/") || "/";
409
- }
410
-
411
- public compile(path: string, params: Record<string, string> = {}) {
412
- for (const [key, value] of Object.entries(params)) {
413
- path = path.replace(`:${key}`, value);
414
- }
415
- return path;
416
- }
417
-
418
- protected renderView(
419
- index: number,
420
- path: string,
421
- view: ReactNode | undefined,
422
- page: PageRoute,
423
- ): ReactNode {
424
- view ??= this.renderEmptyView();
425
-
426
- const element = page.client
427
- ? createElement(
428
- ClientOnly,
429
- typeof page.client === "object" ? page.client : {},
430
- view,
431
- )
432
- : view;
433
-
434
- return createElement(
435
- RouterLayerContext.Provider,
436
- {
437
- value: {
438
- index,
439
- path,
440
- },
441
- },
442
- element,
443
- );
444
- }
445
-
446
- protected readonly configure = $hook({
447
- on: "configure",
448
- handler: () => {
449
- let hasNotFoundHandler = false;
450
- const pages = this.alepha.descriptors($page);
451
-
452
- const hasParent = (it: PageDescriptor) => {
453
- if (it.options.parent) {
454
- return true;
455
- }
456
-
457
- for (const page of pages) {
458
- const children = page.options.children
459
- ? Array.isArray(page.options.children)
460
- ? page.options.children
461
- : page.options.children()
462
- : [];
463
- if (children.includes(it)) {
464
- return true;
465
- }
466
- }
467
- };
468
-
469
- for (const page of pages) {
470
- if (page.options.path === "/*") {
471
- hasNotFoundHandler = true;
472
- }
473
-
474
- // skip children, we only want root pages
475
- if (hasParent(page)) {
476
- continue;
477
- }
478
-
479
- this.add(this.map(pages, page));
480
- }
481
-
482
- if (!hasNotFoundHandler && pages.length > 0) {
483
- // add a default 404 page if not already defined
484
- this.add({
485
- path: "/*",
486
- name: "notFound",
487
- cache: true,
488
- component: NotFoundPage,
489
- onServerResponse: ({ reply }) => {
490
- reply.status = 404;
491
- },
492
- });
493
- }
494
- },
495
- });
496
-
497
- protected map(
498
- pages: Array<PageDescriptor>,
499
- target: PageDescriptor,
500
- ): PageRouteEntry {
501
- const children = target.options.children
502
- ? Array.isArray(target.options.children)
503
- ? target.options.children
504
- : target.options.children()
505
- : [];
506
-
507
- const getChildrenFromParent = (it: PageDescriptor): PageDescriptor[] => {
508
- const children = [];
509
- for (const page of pages) {
510
- if (page.options.parent === it) {
511
- children.push(page);
512
- }
513
- }
514
- return children;
515
- };
516
-
517
- children.push(...getChildrenFromParent(target));
518
-
519
- return {
520
- ...target.options,
521
- name: target.name,
522
- parent: undefined,
523
- children: children.map((it) => this.map(pages, it)),
524
- } as PageRoute;
525
- }
526
-
527
- public add(entry: PageRouteEntry) {
528
- if (this.alepha.isReady()) {
529
- throw new Error("Router is already initialized");
530
- }
531
-
532
- entry.name ??= this.nextId();
533
- const page = entry as PageRoute;
534
-
535
- page.match = this.createMatch(page);
536
- this.pages.push(page);
537
-
538
- if (page.children) {
539
- for (const child of page.children) {
540
- (child as PageRoute).parent = page;
541
- this.add(child);
542
- }
543
- }
544
- }
545
-
546
- protected createMatch(page: PageRoute): string {
547
- let url = page.path ?? "/";
548
- let target = page.parent;
549
- while (target) {
550
- url = `${target.path ?? ""}/${url}`;
551
- target = target.parent;
552
- }
553
-
554
- let path = url.replace(/\/\/+/g, "/");
555
-
556
- if (path.endsWith("/") && path !== "/") {
557
- // remove trailing slash
558
- path = path.slice(0, -1);
559
- }
560
-
561
- return path;
562
- }
563
-
564
- protected _next = 0;
565
-
566
- protected nextId(): string {
567
- this._next += 1;
568
- return `P${this._next}`;
569
- }
36
+ protected readonly log = $logger();
37
+ protected readonly env = $env(envSchema);
38
+ protected readonly alepha = $inject(Alepha);
39
+ protected readonly pages: PageRoute[] = [];
40
+
41
+ public getPages(): PageRoute[] {
42
+ return this.pages;
43
+ }
44
+
45
+ public page(name: string): PageRoute {
46
+ for (const page of this.pages) {
47
+ if (page.name === name) {
48
+ return page;
49
+ }
50
+ }
51
+
52
+ throw new Error(`Page ${name} not found`);
53
+ }
54
+
55
+ public pathname(
56
+ name: string,
57
+ options: {
58
+ params?: Record<string, string>;
59
+ query?: Record<string, string>;
60
+ } = {},
61
+ ) {
62
+ const page = this.page(name);
63
+ if (!page) {
64
+ throw new Error(`Page ${name} not found`);
65
+ }
66
+
67
+ let url = page.path ?? "";
68
+ let parent = page.parent;
69
+ while (parent) {
70
+ url = `${parent.path ?? ""}/${url}`;
71
+ parent = parent.parent;
72
+ }
73
+
74
+ url = this.compile(url, options.params ?? {});
75
+
76
+ if (options.query) {
77
+ const query = new URLSearchParams(options.query);
78
+ if (query.toString()) {
79
+ url += `?${query.toString()}`;
80
+ }
81
+ }
82
+
83
+ return url.replace(/\/\/+/g, "/") || "/";
84
+ }
85
+
86
+ public url(
87
+ name: string,
88
+ options: { params?: Record<string, string>; host?: string } = {},
89
+ ): URL {
90
+ return new URL(
91
+ this.pathname(name, options),
92
+ // use provided base or default to http://localhost
93
+ options.host ?? `http://localhost`,
94
+ );
95
+ }
96
+
97
+ public root(state: ReactRouterState): ReactNode {
98
+ const root = createElement(
99
+ AlephaContext.Provider,
100
+ { value: this.alepha },
101
+ createElement(NestedView, {}, state.layers[0]?.element),
102
+ );
103
+
104
+ if (this.env.REACT_STRICT_MODE) {
105
+ return createElement(StrictMode, {}, root);
106
+ }
107
+
108
+ return root;
109
+ }
110
+
111
+ protected convertStringObjectToObject = (
112
+ schema?: TSchema,
113
+ value?: any,
114
+ ): any => {
115
+ if (t.schema.isObject(schema) && typeof value === "object") {
116
+ for (const key in schema.properties) {
117
+ if (
118
+ t.schema.isObject(schema.properties[key]) &&
119
+ typeof value[key] === "string"
120
+ ) {
121
+ try {
122
+ value[key] = this.alepha.parse(
123
+ schema.properties[key],
124
+ decodeURIComponent(value[key]),
125
+ );
126
+ } catch (e) {
127
+ // ignore
128
+ }
129
+ }
130
+ }
131
+ }
132
+ return value;
133
+ };
134
+
135
+ /**
136
+ * Create a new RouterState based on a given route and request.
137
+ * This method resolves the layers for the route, applying any query and params schemas defined in the route.
138
+ * It also handles errors and redirects.
139
+ */
140
+ public async createLayers(
141
+ route: PageRoute,
142
+ state: ReactRouterState,
143
+ previous: PreviousLayerData[] = [],
144
+ ): Promise<CreateLayersResult> {
145
+ let context: Record<string, any> = {}; // all props
146
+ const stack: Array<RouterStackItem> = [{ route }]; // stack of routes
147
+
148
+ let parent = route.parent;
149
+ while (parent) {
150
+ stack.unshift({ route: parent });
151
+ parent = parent.parent;
152
+ }
153
+
154
+ let forceRefresh = false;
155
+
156
+ for (let i = 0; i < stack.length; i++) {
157
+ const it = stack[i];
158
+ const route = it.route;
159
+ const config: Record<string, any> = {};
160
+
161
+ try {
162
+ this.convertStringObjectToObject(route.schema?.query, state.query);
163
+ config.query = route.schema?.query
164
+ ? this.alepha.parse(route.schema.query, state.query)
165
+ : {};
166
+ } catch (e) {
167
+ it.error = e as Error;
168
+ break;
169
+ }
170
+
171
+ try {
172
+ config.params = route.schema?.params
173
+ ? this.alepha.parse(route.schema.params, state.params)
174
+ : {};
175
+ } catch (e) {
176
+ it.error = e as Error;
177
+ break;
178
+ }
179
+
180
+ // save config
181
+ it.config = {
182
+ ...config,
183
+ };
184
+
185
+ // check if previous layer is the same, reuse if possible
186
+ if (previous?.[i] && !forceRefresh && previous[i].name === route.name) {
187
+ const url = (str?: string) => (str ? str.replace(/\/\/+/g, "/") : "/");
188
+
189
+ const prev = JSON.stringify({
190
+ part: url(previous[i].part),
191
+ params: previous[i].config?.params ?? {},
192
+ });
193
+
194
+ const curr = JSON.stringify({
195
+ part: url(route.path),
196
+ params: config.params ?? {},
197
+ });
198
+
199
+ if (prev === curr) {
200
+ // part is the same, reuse previous layer
201
+ it.props = previous[i].props;
202
+ it.error = previous[i].error;
203
+ it.cache = true;
204
+ context = {
205
+ ...context,
206
+ ...it.props,
207
+ };
208
+ continue;
209
+ }
210
+
211
+ // part is different, force refresh of next layers
212
+ forceRefresh = true;
213
+ }
214
+
215
+ // no resolve, render a basic view by default
216
+ if (!route.resolve) {
217
+ continue;
218
+ }
219
+
220
+ try {
221
+ const args = Object.create(state);
222
+ Object.assign(args, config, context);
223
+ const props = (await route.resolve?.(args)) ?? {};
224
+
225
+ // save props
226
+ it.props = {
227
+ ...props,
228
+ };
229
+
230
+ // add props to context
231
+ context = {
232
+ ...context,
233
+ ...props,
234
+ };
235
+ } catch (e) {
236
+ // check if we need to redirect
237
+ if (e instanceof Redirection) {
238
+ return {
239
+ redirect: e.redirect,
240
+ };
241
+ }
242
+
243
+ this.log.error("Page resolver has failed", e);
244
+
245
+ it.error = e as Error;
246
+ break;
247
+ }
248
+ }
249
+
250
+ let acc = "";
251
+ for (let i = 0; i < stack.length; i++) {
252
+ const it = stack[i];
253
+ const props = it.props ?? {};
254
+
255
+ const params = { ...it.config?.params };
256
+ for (const key of Object.keys(params)) {
257
+ params[key] = String(params[key]);
258
+ }
259
+
260
+ acc += "/";
261
+ acc += it.route.path ? this.compile(it.route.path, params) : "";
262
+ const path = acc.replace(/\/+/, "/");
263
+ const localErrorHandler = this.getErrorHandler(it.route);
264
+ if (localErrorHandler) {
265
+ const onErrorParent = state.onError;
266
+ state.onError = (error, context) => {
267
+ const result = localErrorHandler(error, context);
268
+ // if nothing happen, call the parent
269
+ if (result === undefined) {
270
+ return onErrorParent(error, context);
271
+ }
272
+ return result;
273
+ };
274
+ }
275
+
276
+ // normal use case
277
+ if (!it.error) {
278
+ try {
279
+ const element = await this.createElement(it.route, {
280
+ ...props,
281
+ ...context,
282
+ });
283
+
284
+ state.layers.push({
285
+ name: it.route.name,
286
+ props,
287
+ part: it.route.path,
288
+ config: it.config,
289
+ element: this.renderView(i + 1, path, element, it.route),
290
+ index: i + 1,
291
+ path,
292
+ route: it.route,
293
+ cache: it.cache,
294
+ });
295
+ } catch (e) {
296
+ it.error = e as Error;
297
+ }
298
+ }
299
+
300
+ // handler has thrown an error, render an error view
301
+ if (it.error) {
302
+ try {
303
+ let element: ReactNode | Redirection | undefined =
304
+ await state.onError(it.error, state);
305
+
306
+ if (element === undefined) {
307
+ throw it.error;
308
+ }
309
+
310
+ if (element instanceof Redirection) {
311
+ return {
312
+ redirect: element.redirect,
313
+ };
314
+ }
315
+
316
+ if (element === null) {
317
+ element = this.renderError(it.error);
318
+ }
319
+
320
+ state.layers.push({
321
+ props,
322
+ error: it.error,
323
+ name: it.route.name,
324
+ part: it.route.path,
325
+ config: it.config,
326
+ element: this.renderView(i + 1, path, element, it.route),
327
+ index: i + 1,
328
+ path,
329
+ route: it.route,
330
+ });
331
+ break;
332
+ } catch (e) {
333
+ if (e instanceof Redirection) {
334
+ return {
335
+ redirect: e.redirect,
336
+ };
337
+ }
338
+ throw e;
339
+ }
340
+ }
341
+ }
342
+
343
+ return { state };
344
+ }
345
+
346
+ protected createRedirectionLayer(redirect: string): CreateLayersResult {
347
+ return {
348
+ redirect,
349
+ };
350
+ }
351
+
352
+ protected getErrorHandler(route: PageRoute): ErrorHandler | undefined {
353
+ if (route.errorHandler) return route.errorHandler;
354
+ let parent = route.parent;
355
+ while (parent) {
356
+ if (parent.errorHandler) return parent.errorHandler;
357
+ parent = parent.parent;
358
+ }
359
+ }
360
+
361
+ protected async createElement(
362
+ page: PageRoute,
363
+ props: Record<string, any>,
364
+ ): Promise<ReactNode> {
365
+ if (page.lazy && page.component) {
366
+ this.log.warn(
367
+ `Page ${page.name} has both lazy and component options, lazy will be used`,
368
+ );
369
+ }
370
+
371
+ if (page.lazy) {
372
+ const component = await page.lazy(); // load component
373
+ return createElement(component.default, props);
374
+ }
375
+
376
+ if (page.component) {
377
+ return createElement(page.component, props);
378
+ }
379
+
380
+ return undefined;
381
+ }
382
+
383
+ public renderError(error: Error): ReactNode {
384
+ return createElement(ErrorViewer, { error, alepha: this.alepha });
385
+ }
386
+
387
+ public renderEmptyView(): ReactNode {
388
+ return createElement(NestedView, {});
389
+ }
390
+
391
+ public href(
392
+ page: { options: { name?: string } },
393
+ params: Record<string, any> = {},
394
+ ): string {
395
+ const found = this.pages.find((it) => it.name === page.options.name);
396
+ if (!found) {
397
+ throw new Error(`Page ${page.options.name} not found`);
398
+ }
399
+
400
+ let url = found.path ?? "";
401
+ let parent = found.parent;
402
+ while (parent) {
403
+ url = `${parent.path ?? ""}/${url}`;
404
+ parent = parent.parent;
405
+ }
406
+
407
+ url = this.compile(url, params);
408
+
409
+ return url.replace(/\/\/+/g, "/") || "/";
410
+ }
411
+
412
+ public compile(path: string, params: Record<string, string> = {}) {
413
+ for (const [key, value] of Object.entries(params)) {
414
+ path = path.replace(`:${key}`, value);
415
+ }
416
+ return path;
417
+ }
418
+
419
+ protected renderView(
420
+ index: number,
421
+ path: string,
422
+ view: ReactNode | undefined,
423
+ page: PageRoute,
424
+ ): ReactNode {
425
+ view ??= this.renderEmptyView();
426
+
427
+ const element = page.client
428
+ ? createElement(
429
+ ClientOnly,
430
+ typeof page.client === "object" ? page.client : {},
431
+ view,
432
+ )
433
+ : view;
434
+
435
+ return createElement(
436
+ RouterLayerContext.Provider,
437
+ {
438
+ value: {
439
+ index,
440
+ path,
441
+ },
442
+ },
443
+ element,
444
+ );
445
+ }
446
+
447
+ protected readonly configure = $hook({
448
+ on: "configure",
449
+ handler: () => {
450
+ let hasNotFoundHandler = false;
451
+ const pages = this.alepha.descriptors($page);
452
+
453
+ const hasParent = (it: PageDescriptor) => {
454
+ if (it.options.parent) {
455
+ return true;
456
+ }
457
+
458
+ for (const page of pages) {
459
+ const children = page.options.children
460
+ ? Array.isArray(page.options.children)
461
+ ? page.options.children
462
+ : page.options.children()
463
+ : [];
464
+ if (children.includes(it)) {
465
+ return true;
466
+ }
467
+ }
468
+ };
469
+
470
+ for (const page of pages) {
471
+ if (page.options.path === "/*") {
472
+ hasNotFoundHandler = true;
473
+ }
474
+
475
+ // skip children, we only want root pages
476
+ if (hasParent(page)) {
477
+ continue;
478
+ }
479
+
480
+ this.add(this.map(pages, page));
481
+ }
482
+
483
+ if (!hasNotFoundHandler && pages.length > 0) {
484
+ // add a default 404 page if not already defined
485
+ this.add({
486
+ path: "/*",
487
+ name: "notFound",
488
+ cache: true,
489
+ component: NotFoundPage,
490
+ onServerResponse: ({ reply }) => {
491
+ reply.status = 404;
492
+ },
493
+ });
494
+ }
495
+ },
496
+ });
497
+
498
+ protected map(
499
+ pages: Array<PageDescriptor>,
500
+ target: PageDescriptor,
501
+ ): PageRouteEntry {
502
+ const children = target.options.children
503
+ ? Array.isArray(target.options.children)
504
+ ? target.options.children
505
+ : target.options.children()
506
+ : [];
507
+
508
+ const getChildrenFromParent = (it: PageDescriptor): PageDescriptor[] => {
509
+ const children = [];
510
+ for (const page of pages) {
511
+ if (page.options.parent === it) {
512
+ children.push(page);
513
+ }
514
+ }
515
+ return children;
516
+ };
517
+
518
+ children.push(...getChildrenFromParent(target));
519
+
520
+ return {
521
+ ...target.options,
522
+ name: target.name,
523
+ parent: undefined,
524
+ children: children.map((it) => this.map(pages, it)),
525
+ } as PageRoute;
526
+ }
527
+
528
+ public add(entry: PageRouteEntry) {
529
+ if (this.alepha.isReady()) {
530
+ throw new AlephaError("Router is already initialized");
531
+ }
532
+
533
+ entry.name ??= this.nextId();
534
+ const page = entry as PageRoute;
535
+
536
+ page.match = this.createMatch(page);
537
+ this.pages.push(page);
538
+
539
+ if (page.children) {
540
+ for (const child of page.children) {
541
+ (child as PageRoute).parent = page;
542
+ this.add(child);
543
+ }
544
+ }
545
+ }
546
+
547
+ protected createMatch(page: PageRoute): string {
548
+ let url = page.path ?? "/";
549
+ let target = page.parent;
550
+ while (target) {
551
+ url = `${target.path ?? ""}/${url}`;
552
+ target = target.parent;
553
+ }
554
+
555
+ let path = url.replace(/\/\/+/g, "/");
556
+
557
+ if (path.endsWith("/") && path !== "/") {
558
+ // remove trailing slash
559
+ path = path.slice(0, -1);
560
+ }
561
+
562
+ return path;
563
+ }
564
+
565
+ protected _next = 0;
566
+
567
+ protected nextId(): string {
568
+ this._next += 1;
569
+ return `P${this._next}`;
570
+ }
570
571
  }
571
572
 
572
573
  export const isPageRoute = (it: any): it is PageRoute => {
573
- return (
574
- it &&
575
- typeof it === "object" &&
576
- typeof it.path === "string" &&
577
- typeof it.page === "object"
578
- );
574
+ return (
575
+ it &&
576
+ typeof it === "object" &&
577
+ typeof it.path === "string" &&
578
+ typeof it.page === "object"
579
+ );
579
580
  };
580
581
 
581
582
  export interface PageRouteEntry
582
- extends Omit<PageDescriptorOptions, "children" | "parent"> {
583
- children?: PageRouteEntry[];
583
+ extends Omit<PageDescriptorOptions, "children" | "parent"> {
584
+ children?: PageRouteEntry[];
584
585
  }
585
586
 
586
587
  export interface PageRoute extends PageRouteEntry {
587
- type: "page";
588
- name: string;
589
- parent?: PageRoute;
590
- match: string;
588
+ type: "page";
589
+ name: string;
590
+ parent?: PageRoute;
591
+ match: string;
591
592
  }
592
593
 
593
594
  export interface Layer {
594
- config?: {
595
- query?: Record<string, any>;
596
- params?: Record<string, any>;
597
- // stack of resolved props
598
- context?: Record<string, any>;
599
- };
600
-
601
- name: string;
602
- props?: Record<string, any>;
603
- error?: Error;
604
- part?: string;
605
- element: ReactNode;
606
- index: number;
607
- path: string;
608
- route?: PageRoute;
609
- cache?: boolean;
595
+ config?: {
596
+ query?: Record<string, any>;
597
+ params?: Record<string, any>;
598
+ // stack of resolved props
599
+ context?: Record<string, any>;
600
+ };
601
+
602
+ name: string;
603
+ props?: Record<string, any>;
604
+ error?: Error;
605
+ part?: string;
606
+ element: ReactNode;
607
+ index: number;
608
+ path: string;
609
+ route?: PageRoute;
610
+ cache?: boolean;
610
611
  }
611
612
 
612
613
  export type PreviousLayerData = Omit<Layer, "element" | "index" | "path">;
613
614
 
614
615
  export interface AnchorProps {
615
- href: string;
616
- onClick: (ev?: any) => any;
616
+ href: string;
617
+ onClick: (ev?: any) => any;
617
618
  }
618
619
 
619
620
  export interface ReactRouterState {
620
- /**
621
- * Stack of layers for the current page.
622
- */
623
- layers: Array<Layer>;
624
-
625
- /**
626
- * URL of the current page.
627
- */
628
- url: URL;
629
-
630
- /**
631
- * Error handler for the current page.
632
- */
633
- onError: ErrorHandler;
634
-
635
- /**
636
- * Params extracted from the URL for the current page.
637
- */
638
- params: Record<string, any>;
639
-
640
- /**
641
- * Query parameters extracted from the URL for the current page.
642
- */
643
- query: Record<string, string>;
644
-
645
- /**
646
- * Optional meta information associated with the current page.
647
- */
648
- meta: Record<string, any>;
621
+ /**
622
+ * Stack of layers for the current page.
623
+ */
624
+ layers: Array<Layer>;
625
+
626
+ /**
627
+ * URL of the current page.
628
+ */
629
+ url: URL;
630
+
631
+ /**
632
+ * Error handler for the current page.
633
+ */
634
+ onError: ErrorHandler;
635
+
636
+ /**
637
+ * Params extracted from the URL for the current page.
638
+ */
639
+ params: Record<string, any>;
640
+
641
+ /**
642
+ * Query parameters extracted from the URL for the current page.
643
+ */
644
+ query: Record<string, string>;
645
+
646
+ /**
647
+ * Optional meta information associated with the current page.
648
+ */
649
+ meta: Record<string, any>;
649
650
  }
650
651
 
651
652
  export interface RouterStackItem {
652
- route: PageRoute;
653
- config?: Record<string, any>;
654
- props?: Record<string, any>;
655
- error?: Error;
656
- cache?: boolean;
653
+ route: PageRoute;
654
+ config?: Record<string, any>;
655
+ props?: Record<string, any>;
656
+ error?: Error;
657
+ cache?: boolean;
657
658
  }
658
659
 
659
660
  export interface TransitionOptions {
660
- previous?: PreviousLayerData[];
661
+ previous?: PreviousLayerData[];
661
662
  }
662
663
 
663
664
  export interface CreateLayersResult {
664
- redirect?: string;
665
- state?: ReactRouterState;
665
+ redirect?: string;
666
+ state?: ReactRouterState;
666
667
  }