@alepha/react 0.10.5 → 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,654 +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 props =
221
- (await route.resolve?.({
222
- ...state, // request
223
- ...config, // params, query
224
- ...context, // previous props
225
- } as any)) ?? {};
226
-
227
- // save props
228
- it.props = {
229
- ...props,
230
- };
231
-
232
- // add props to context
233
- context = {
234
- ...context,
235
- ...props,
236
- };
237
- } catch (e) {
238
- // check if we need to redirect
239
- if (e instanceof Redirection) {
240
- return {
241
- redirect: e.redirect,
242
- };
243
- }
244
-
245
- this.log.error("Page resolver has failed", e);
246
-
247
- it.error = e as Error;
248
- break;
249
- }
250
- }
251
-
252
- let acc = "";
253
- for (let i = 0; i < stack.length; i++) {
254
- const it = stack[i];
255
- const props = it.props ?? {};
256
-
257
- const params = { ...it.config?.params };
258
- for (const key of Object.keys(params)) {
259
- params[key] = String(params[key]);
260
- }
261
-
262
- acc += "/";
263
- acc += it.route.path ? this.compile(it.route.path, params) : "";
264
- const path = acc.replace(/\/+/, "/");
265
- const localErrorHandler = this.getErrorHandler(it.route);
266
- if (localErrorHandler) {
267
- const onErrorParent = state.onError;
268
- state.onError = (error, context) => {
269
- const result = localErrorHandler(error, context);
270
- // if nothing happen, call the parent
271
- if (result === undefined) {
272
- return onErrorParent(error, context);
273
- }
274
- return result;
275
- };
276
- }
277
-
278
- // normal use case
279
- if (!it.error) {
280
- try {
281
- const element = await this.createElement(it.route, {
282
- ...props,
283
- ...context,
284
- });
285
-
286
- state.layers.push({
287
- name: it.route.name,
288
- props,
289
- part: it.route.path,
290
- config: it.config,
291
- element: this.renderView(i + 1, path, element, it.route),
292
- index: i + 1,
293
- path,
294
- route: it.route,
295
- cache: it.cache,
296
- });
297
- } catch (e) {
298
- it.error = e as Error;
299
- }
300
- }
301
-
302
- // handler has thrown an error, render an error view
303
- if (it.error) {
304
- try {
305
- let element: ReactNode | Redirection | undefined =
306
- await state.onError(it.error, state);
307
-
308
- if (element === undefined) {
309
- throw it.error;
310
- }
311
-
312
- if (element instanceof Redirection) {
313
- return {
314
- redirect: element.redirect,
315
- };
316
- }
317
-
318
- if (element === null) {
319
- element = this.renderError(it.error);
320
- }
321
-
322
- state.layers.push({
323
- props,
324
- error: it.error,
325
- name: it.route.name,
326
- part: it.route.path,
327
- config: it.config,
328
- element: this.renderView(i + 1, path, element, it.route),
329
- index: i + 1,
330
- path,
331
- route: it.route,
332
- });
333
- break;
334
- } catch (e) {
335
- if (e instanceof Redirection) {
336
- return {
337
- redirect: e.redirect,
338
- };
339
- }
340
- throw e;
341
- }
342
- }
343
- }
344
-
345
- return { state };
346
- }
347
-
348
- protected createRedirectionLayer(redirect: string): CreateLayersResult {
349
- return {
350
- redirect,
351
- };
352
- }
353
-
354
- protected getErrorHandler(route: PageRoute): ErrorHandler | undefined {
355
- if (route.errorHandler) return route.errorHandler;
356
- let parent = route.parent;
357
- while (parent) {
358
- if (parent.errorHandler) return parent.errorHandler;
359
- parent = parent.parent;
360
- }
361
- }
362
-
363
- protected async createElement(
364
- page: PageRoute,
365
- props: Record<string, any>,
366
- ): Promise<ReactNode> {
367
- if (page.lazy && page.component) {
368
- this.log.warn(
369
- `Page ${page.name} has both lazy and component options, lazy will be used`,
370
- );
371
- }
372
-
373
- if (page.lazy) {
374
- const component = await page.lazy(); // load component
375
- return createElement(component.default, props);
376
- }
377
-
378
- if (page.component) {
379
- return createElement(page.component, props);
380
- }
381
-
382
- return undefined;
383
- }
384
-
385
- public renderError(error: Error): ReactNode {
386
- return createElement(ErrorViewer, { error, alepha: this.alepha });
387
- }
388
-
389
- public renderEmptyView(): ReactNode {
390
- return createElement(NestedView, {});
391
- }
392
-
393
- public href(
394
- page: { options: { name?: string } },
395
- params: Record<string, any> = {},
396
- ): string {
397
- const found = this.pages.find((it) => it.name === page.options.name);
398
- if (!found) {
399
- throw new Error(`Page ${page.options.name} not found`);
400
- }
401
-
402
- let url = found.path ?? "";
403
- let parent = found.parent;
404
- while (parent) {
405
- url = `${parent.path ?? ""}/${url}`;
406
- parent = parent.parent;
407
- }
408
-
409
- url = this.compile(url, params);
410
-
411
- return url.replace(/\/\/+/g, "/") || "/";
412
- }
413
-
414
- public compile(path: string, params: Record<string, string> = {}) {
415
- for (const [key, value] of Object.entries(params)) {
416
- path = path.replace(`:${key}`, value);
417
- }
418
- return path;
419
- }
420
-
421
- protected renderView(
422
- index: number,
423
- path: string,
424
- view: ReactNode | undefined,
425
- page: PageRoute,
426
- ): ReactNode {
427
- view ??= this.renderEmptyView();
428
-
429
- const element = page.client
430
- ? createElement(
431
- ClientOnly,
432
- typeof page.client === "object" ? page.client : {},
433
- view,
434
- )
435
- : view;
436
-
437
- return createElement(
438
- RouterLayerContext.Provider,
439
- {
440
- value: {
441
- index,
442
- path,
443
- },
444
- },
445
- element,
446
- );
447
- }
448
-
449
- protected readonly configure = $hook({
450
- on: "configure",
451
- handler: () => {
452
- let hasNotFoundHandler = false;
453
- const pages = this.alepha.descriptors($page);
454
-
455
- const hasParent = (it: PageDescriptor) => {
456
- if (it.options.parent) {
457
- return true;
458
- }
459
-
460
- for (const page of pages) {
461
- const children = page.options.children
462
- ? Array.isArray(page.options.children)
463
- ? page.options.children
464
- : page.options.children()
465
- : [];
466
- if (children.includes(it)) {
467
- return true;
468
- }
469
- }
470
- };
471
-
472
- for (const page of pages) {
473
- if (page.options.path === "/*") {
474
- hasNotFoundHandler = true;
475
- }
476
-
477
- // skip children, we only want root pages
478
- if (hasParent(page)) {
479
- continue;
480
- }
481
-
482
- this.add(this.map(pages, page));
483
- }
484
-
485
- if (!hasNotFoundHandler && pages.length > 0) {
486
- // add a default 404 page if not already defined
487
- this.add({
488
- path: "/*",
489
- name: "notFound",
490
- cache: true,
491
- component: NotFoundPage,
492
- onServerResponse: ({ reply }) => {
493
- reply.status = 404;
494
- },
495
- });
496
- }
497
- },
498
- });
499
-
500
- protected map(
501
- pages: Array<PageDescriptor>,
502
- target: PageDescriptor,
503
- ): PageRouteEntry {
504
- const children = target.options.children
505
- ? Array.isArray(target.options.children)
506
- ? target.options.children
507
- : target.options.children()
508
- : [];
509
-
510
- const getChildrenFromParent = (it: PageDescriptor): PageDescriptor[] => {
511
- const children = [];
512
- for (const page of pages) {
513
- if (page.options.parent === it) {
514
- children.push(page);
515
- }
516
- }
517
- return children;
518
- };
519
-
520
- children.push(...getChildrenFromParent(target));
521
-
522
- return {
523
- ...target.options,
524
- name: target.name,
525
- parent: undefined,
526
- children: children.map((it) => this.map(pages, it)),
527
- } as PageRoute;
528
- }
529
-
530
- public add(entry: PageRouteEntry) {
531
- if (this.alepha.isReady()) {
532
- throw new Error("Router is already initialized");
533
- }
534
-
535
- entry.name ??= this.nextId();
536
- const page = entry as PageRoute;
537
-
538
- page.match = this.createMatch(page);
539
- this.pages.push(page);
540
-
541
- if (page.children) {
542
- for (const child of page.children) {
543
- (child as PageRoute).parent = page;
544
- this.add(child);
545
- }
546
- }
547
- }
548
-
549
- protected createMatch(page: PageRoute): string {
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("/") && path !== "/") {
560
- // remove trailing slash
561
- path = path.slice(0, -1);
562
- }
563
-
564
- return path;
565
- }
566
-
567
- protected _next = 0;
568
-
569
- protected nextId(): string {
570
- this._next += 1;
571
- return `P${this._next}`;
572
- }
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
+ }
573
571
  }
574
572
 
575
573
  export const isPageRoute = (it: any): it is PageRoute => {
576
- return (
577
- it &&
578
- typeof it === "object" &&
579
- typeof it.path === "string" &&
580
- typeof it.page === "object"
581
- );
574
+ return (
575
+ it &&
576
+ typeof it === "object" &&
577
+ typeof it.path === "string" &&
578
+ typeof it.page === "object"
579
+ );
582
580
  };
583
581
 
584
582
  export interface PageRouteEntry
585
- extends Omit<PageDescriptorOptions, "children" | "parent"> {
586
- children?: PageRouteEntry[];
583
+ extends Omit<PageDescriptorOptions, "children" | "parent"> {
584
+ children?: PageRouteEntry[];
587
585
  }
588
586
 
589
587
  export interface PageRoute extends PageRouteEntry {
590
- type: "page";
591
- name: string;
592
- parent?: PageRoute;
593
- match: string;
588
+ type: "page";
589
+ name: string;
590
+ parent?: PageRoute;
591
+ match: string;
594
592
  }
595
593
 
596
594
  export interface Layer {
597
- config?: {
598
- query?: Record<string, any>;
599
- params?: Record<string, any>;
600
- // stack of resolved props
601
- context?: Record<string, any>;
602
- };
603
-
604
- name: string;
605
- props?: Record<string, any>;
606
- error?: Error;
607
- part?: string;
608
- element: ReactNode;
609
- index: number;
610
- path: string;
611
- route?: PageRoute;
612
- 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;
613
611
  }
614
612
 
615
613
  export type PreviousLayerData = Omit<Layer, "element" | "index" | "path">;
616
614
 
617
615
  export interface AnchorProps {
618
- href: string;
619
- onClick: (ev?: any) => any;
616
+ href: string;
617
+ onClick: (ev?: any) => any;
620
618
  }
621
619
 
622
620
  export interface ReactRouterState {
623
- /**
624
- * Stack of layers for the current page.
625
- */
626
- layers: Array<Layer>;
627
-
628
- /**
629
- * URL of the current page.
630
- */
631
- url: URL;
632
-
633
- /**
634
- * Error handler for the current page.
635
- */
636
- onError: ErrorHandler;
637
-
638
- /**
639
- * Params extracted from the URL for the current page.
640
- */
641
- params: Record<string, any>;
642
-
643
- /**
644
- * Query parameters extracted from the URL for the current page.
645
- */
646
- query: Record<string, string>;
647
-
648
- /**
649
- * Optional meta information associated with the current page.
650
- */
651
- 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>;
652
650
  }
653
651
 
654
652
  export interface RouterStackItem {
655
- route: PageRoute;
656
- config?: Record<string, any>;
657
- props?: Record<string, any>;
658
- error?: Error;
659
- cache?: boolean;
653
+ route: PageRoute;
654
+ config?: Record<string, any>;
655
+ props?: Record<string, any>;
656
+ error?: Error;
657
+ cache?: boolean;
660
658
  }
661
659
 
662
660
  export interface TransitionOptions {
663
- previous?: PreviousLayerData[];
661
+ previous?: PreviousLayerData[];
664
662
  }
665
663
 
666
664
  export interface CreateLayersResult {
667
- redirect?: string;
668
- state?: ReactRouterState;
665
+ redirect?: string;
666
+ state?: ReactRouterState;
669
667
  }