@alepha/react 0.6.2 → 0.6.4

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.
Files changed (38) hide show
  1. package/README.md +1 -28
  2. package/dist/index.browser.cjs +20 -23
  3. package/dist/index.browser.cjs.map +1 -0
  4. package/dist/index.browser.js +8 -7
  5. package/dist/index.browser.js.map +1 -0
  6. package/dist/index.cjs +234 -542
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.d.ts +218 -682
  9. package/dist/index.js +219 -522
  10. package/dist/index.js.map +1 -0
  11. package/dist/{useAuth-B9ypF48n.cjs → useActive-BGtt_RNQ.cjs} +310 -475
  12. package/dist/useActive-BGtt_RNQ.cjs.map +1 -0
  13. package/dist/{useAuth-Ps01oe8e.js → useActive-QkvcaSmu.js} +309 -471
  14. package/dist/useActive-QkvcaSmu.js.map +1 -0
  15. package/package.json +12 -10
  16. package/src/components/Link.tsx +35 -0
  17. package/src/components/NestedView.tsx +36 -0
  18. package/src/contexts/RouterContext.ts +18 -0
  19. package/src/contexts/RouterLayerContext.ts +10 -0
  20. package/src/descriptors/$page.ts +143 -0
  21. package/src/errors/RedirectionError.ts +7 -0
  22. package/src/hooks/RouterHookApi.ts +156 -0
  23. package/src/hooks/useActive.ts +57 -0
  24. package/src/hooks/useClient.ts +6 -0
  25. package/src/hooks/useInject.ts +14 -0
  26. package/src/hooks/useQueryParams.ts +59 -0
  27. package/src/hooks/useRouter.ts +25 -0
  28. package/src/hooks/useRouterEvents.ts +43 -0
  29. package/src/hooks/useRouterState.ts +23 -0
  30. package/src/index.browser.ts +21 -0
  31. package/src/index.shared.ts +15 -0
  32. package/src/index.ts +48 -0
  33. package/src/providers/BrowserHeadProvider.ts +43 -0
  34. package/src/providers/BrowserRouterProvider.ts +146 -0
  35. package/src/providers/PageDescriptorProvider.ts +534 -0
  36. package/src/providers/ReactBrowserProvider.ts +223 -0
  37. package/src/providers/ReactServerProvider.ts +278 -0
  38. package/src/providers/ServerHeadProvider.ts +91 -0
@@ -0,0 +1,534 @@
1
+ import { $hook, $inject, $logger, Alepha, EventEmitter } from "@alepha/core";
2
+ import type { HttpClientLink } from "@alepha/server";
3
+ import { type ReactNode, createElement } from "react";
4
+ import NestedView from "../components/NestedView.tsx";
5
+ import { RouterContext } from "../contexts/RouterContext.ts";
6
+ import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
7
+ import {
8
+ $page,
9
+ type Head,
10
+ type PageDescriptorOptions,
11
+ } from "../descriptors/$page.ts";
12
+ import { RedirectionError } from "../errors/RedirectionError.ts";
13
+
14
+ export class PageDescriptorProvider {
15
+ protected readonly log = $logger();
16
+ protected readonly alepha = $inject(Alepha);
17
+ protected readonly pages: PageRoute[] = [];
18
+
19
+ public getPages(): PageRoute[] {
20
+ return this.pages;
21
+ }
22
+
23
+ public page(name: string): PageRoute {
24
+ for (const page of this.pages) {
25
+ if (page.name === name) {
26
+ return page;
27
+ }
28
+ }
29
+
30
+ throw new Error(`Page ${name} not found`);
31
+ }
32
+
33
+ public root(
34
+ state: RouterState,
35
+ context: PageReactContext = {},
36
+ events?: EventEmitter<RouterEvents>,
37
+ ): ReactNode {
38
+ return createElement(
39
+ RouterContext.Provider,
40
+ {
41
+ value: {
42
+ alepha: this.alepha,
43
+ state,
44
+ context,
45
+ events: events ?? new EventEmitter<RouterEvents>(),
46
+ },
47
+ },
48
+ createElement(NestedView, {}, state.layers[0]?.element),
49
+ );
50
+ }
51
+
52
+ public async createLayers(
53
+ route: PageRoute,
54
+ request: PageRequest,
55
+ ): Promise<CreateLayersResult> {
56
+ const { pathname, search } = request.url;
57
+ const layers: Layer[] = []; // result layers
58
+ let context: Record<string, any> = {}; // all props
59
+ const stack: Array<RouterStackItem> = [{ route }]; // stack of routes
60
+
61
+ let parent = route.parent;
62
+ while (parent) {
63
+ stack.unshift({ route: parent });
64
+ parent = parent.parent;
65
+ }
66
+
67
+ let forceRefresh = false;
68
+
69
+ for (let i = 0; i < stack.length; i++) {
70
+ const it = stack[i];
71
+ const route = it.route;
72
+ const config: Record<string, any> = {};
73
+
74
+ try {
75
+ config.query = route.schema?.query
76
+ ? this.alepha.parse(route.schema.query, request.query)
77
+ : request.query;
78
+ } catch (e) {
79
+ it.error = e as Error;
80
+ break;
81
+ }
82
+
83
+ try {
84
+ config.params = route.schema?.params
85
+ ? this.alepha.parse(route.schema.params, request.params)
86
+ : request.params;
87
+ } catch (e) {
88
+ it.error = e as Error;
89
+ break;
90
+ }
91
+
92
+ // save config
93
+ it.config = {
94
+ ...config,
95
+ };
96
+
97
+ // no resolve, render a basic view by default
98
+ if (!route.resolve) {
99
+ continue;
100
+ }
101
+
102
+ // check if previous layer is the same, reuse if possible
103
+ const previous = request.previous;
104
+ if (previous?.[i] && !forceRefresh && previous[i].name === route.name) {
105
+ const url = (str?: string) => (str ? str.replace(/\/\/+/g, "/") : "/");
106
+
107
+ const prev = JSON.stringify({
108
+ part: url(previous[i].part),
109
+ params: previous[i].config?.params ?? {},
110
+ });
111
+
112
+ const curr = JSON.stringify({
113
+ part: url(route.path),
114
+ params: config.params ?? {},
115
+ });
116
+
117
+ if (prev === curr) {
118
+ // part is the same, reuse previous layer
119
+ it.props = previous[i].props;
120
+ it.error = previous[i].error;
121
+ context = {
122
+ ...context,
123
+ ...it.props,
124
+ };
125
+ continue;
126
+ }
127
+ // part is different, force refresh of next layers
128
+ forceRefresh = true;
129
+ }
130
+
131
+ try {
132
+ const props =
133
+ (await route.resolve?.({
134
+ ...request, // request
135
+ ...config, // params, query
136
+ ...context, // previous props
137
+ } as any)) ?? {};
138
+
139
+ // save props
140
+ it.props = {
141
+ ...props,
142
+ };
143
+
144
+ // add props to context
145
+ context = {
146
+ ...context,
147
+ ...props,
148
+ };
149
+ } catch (e) {
150
+ // check if we need to redirect
151
+ if (e instanceof RedirectionError) {
152
+ return {
153
+ layers: [],
154
+ redirect: typeof e.page === "string" ? e.page : this.href(e.page),
155
+ head: request.head,
156
+ pathname,
157
+ search,
158
+ };
159
+ }
160
+
161
+ this.log.error(e);
162
+
163
+ it.error = e as Error;
164
+ break;
165
+ }
166
+ }
167
+
168
+ let acc = "";
169
+ for (let i = 0; i < stack.length; i++) {
170
+ const it = stack[i];
171
+ const props = it.props ?? {};
172
+
173
+ const params = { ...it.config?.params };
174
+ for (const key of Object.keys(params)) {
175
+ params[key] = String(params[key]);
176
+ }
177
+
178
+ if (it.route.head && !it.error) {
179
+ this.fillHead(it.route, request, {
180
+ ...props,
181
+ ...context,
182
+ });
183
+ }
184
+
185
+ acc += "/";
186
+ acc += it.route.path ? this.compile(it.route.path, params) : "";
187
+ const path = acc.replace(/\/+/, "/");
188
+
189
+ // handler has thrown an error, render an error view
190
+ if (it.error) {
191
+ const errorHandler = this.getErrorHandler(it.route);
192
+ const element = await (errorHandler
193
+ ? errorHandler({
194
+ ...it.config,
195
+ error: it.error,
196
+ url: "",
197
+ })
198
+ : this.renderError(it.error));
199
+
200
+ layers.push({
201
+ props,
202
+ error: it.error,
203
+ name: it.route.name,
204
+ part: it.route.path,
205
+ config: it.config,
206
+ element: this.renderView(i + 1, path, element),
207
+ index: i + 1,
208
+ path,
209
+ });
210
+ break;
211
+ }
212
+
213
+ // normal use case
214
+
215
+ const layer = await this.createElement(it.route, {
216
+ ...props,
217
+ ...context,
218
+ });
219
+
220
+ layers.push({
221
+ name: it.route.name,
222
+ props,
223
+ part: it.route.path,
224
+ config: it.config,
225
+ element: this.renderView(i + 1, path, layer),
226
+ index: i + 1,
227
+ path,
228
+ });
229
+ }
230
+
231
+ return { layers, head: request.head, pathname, search };
232
+ }
233
+
234
+ protected getErrorHandler(route: PageRoute) {
235
+ if (route.errorHandler) return route.errorHandler;
236
+ let parent = route.parent;
237
+ while (parent) {
238
+ if (parent.errorHandler) return parent.errorHandler;
239
+ parent = parent.parent;
240
+ }
241
+ }
242
+
243
+ protected async createElement(
244
+ page: PageRoute,
245
+ props: Record<string, any>,
246
+ ): Promise<ReactNode> {
247
+ if (page.lazy) {
248
+ const component = await page.lazy(); // load component
249
+ return createElement(component.default, props);
250
+ }
251
+
252
+ if (page.component) {
253
+ return createElement(page.component, props);
254
+ }
255
+
256
+ return undefined;
257
+ }
258
+
259
+ protected fillHead(
260
+ page: PageRoute,
261
+ ctx: PageRequest,
262
+ props: Record<string, any>,
263
+ ): void {
264
+ if (!page.head) {
265
+ return;
266
+ }
267
+
268
+ ctx.head ??= {};
269
+
270
+ const head =
271
+ typeof page.head === "function" ? page.head(props, ctx.head) : page.head;
272
+
273
+ if (head.title) {
274
+ ctx.head ??= {};
275
+
276
+ if (ctx.head.titleSeparator) {
277
+ ctx.head.title = `${head.title}${ctx.head.titleSeparator}${ctx.head.title}`;
278
+ } else {
279
+ ctx.head.title = head.title;
280
+ }
281
+
282
+ ctx.head.titleSeparator = head.titleSeparator;
283
+ }
284
+
285
+ if (head.htmlAttributes) {
286
+ ctx.head.htmlAttributes = {
287
+ ...ctx.head.htmlAttributes,
288
+ ...head.htmlAttributes,
289
+ };
290
+ }
291
+
292
+ if (head.bodyAttributes) {
293
+ ctx.head.bodyAttributes = {
294
+ ...ctx.head.bodyAttributes,
295
+ ...head.bodyAttributes,
296
+ };
297
+ }
298
+
299
+ if (head.meta) {
300
+ ctx.head.meta = [...(ctx.head.meta ?? []), ...(head.meta ?? [])];
301
+ }
302
+ }
303
+
304
+ public renderError(e: Error): ReactNode {
305
+ return createElement("pre", { style: { overflow: "auto" } }, `${e.stack}`);
306
+ }
307
+
308
+ public renderEmptyView(): ReactNode {
309
+ return createElement(NestedView, {});
310
+ }
311
+
312
+ public href(
313
+ page: { options: { name?: string } },
314
+ params: Record<string, any> = {},
315
+ ): string {
316
+ const found = this.pages.find((it) => it.name === page.options.name);
317
+ if (!found) {
318
+ throw new Error(`Page ${page.options.name} not found`);
319
+ }
320
+
321
+ let url = found.path ?? "";
322
+ let parent = found.parent;
323
+ while (parent) {
324
+ url = `${parent.path ?? ""}/${url}`;
325
+ parent = parent.parent;
326
+ }
327
+
328
+ url = this.compile(url, params);
329
+
330
+ return url.replace(/\/\/+/g, "/") || "/";
331
+ }
332
+
333
+ public compile(path: string, params: Record<string, string> = {}) {
334
+ for (const [key, value] of Object.entries(params)) {
335
+ path = path.replace(`:${key}`, value);
336
+ }
337
+ return path;
338
+ }
339
+
340
+ protected renderView(
341
+ index: number,
342
+ path: string,
343
+ view: ReactNode = this.renderEmptyView(),
344
+ ): ReactNode {
345
+ return createElement(
346
+ RouterLayerContext.Provider,
347
+ {
348
+ value: {
349
+ index,
350
+ path,
351
+ },
352
+ },
353
+ view,
354
+ );
355
+ }
356
+
357
+ protected readonly configure = $hook({
358
+ name: "configure",
359
+ handler: () => {
360
+ const pages = this.alepha.getDescriptorValues($page);
361
+ for (const { value, key } of pages) {
362
+ value.options.name ??= key;
363
+
364
+ // skip children, we only want root pages
365
+ if (value.options.parent) {
366
+ continue;
367
+ }
368
+
369
+ this.add(this.map(pages, value));
370
+ }
371
+ },
372
+ });
373
+
374
+ protected map(
375
+ pages: Array<{ value: { options: PageDescriptorOptions } }>,
376
+ target: { options: PageDescriptorOptions },
377
+ ): PageRouteEntry {
378
+ const children = target.options.children ?? [];
379
+
380
+ for (const it of pages) {
381
+ if (it.value.options.parent === target) {
382
+ children.push(it.value);
383
+ }
384
+ }
385
+
386
+ return {
387
+ ...target.options,
388
+ parent: undefined,
389
+ children: children.map((it) => this.map(pages, it)),
390
+ } as PageRoute;
391
+ }
392
+
393
+ public add(entry: PageRouteEntry) {
394
+ if (this.alepha.isReady()) {
395
+ throw new Error("Router is already initialized");
396
+ }
397
+
398
+ entry.name ??= this.nextId();
399
+ const page = entry as PageRoute;
400
+
401
+ page.match = this.createMatch(page);
402
+ this.pages.push(page);
403
+
404
+ if (page.children) {
405
+ for (const child of page.children) {
406
+ (child as PageRoute).parent = page;
407
+ this.add(child);
408
+ }
409
+ }
410
+ }
411
+
412
+ protected createMatch(page: PageRoute): string {
413
+ let url = page.path ?? "/";
414
+ let target = page.parent;
415
+ while (target) {
416
+ url = `${target.path ?? ""}/${url}`;
417
+ target = target.parent;
418
+ }
419
+
420
+ let path = url.replace(/\/\/+/g, "/");
421
+
422
+ if (path.endsWith("/") && path !== "/") {
423
+ // remove trailing slash
424
+ path = path.slice(0, -1);
425
+ }
426
+
427
+ return path;
428
+ }
429
+
430
+ protected _next = 0;
431
+
432
+ protected nextId(): string {
433
+ this._next += 1;
434
+ return `P${this._next}`;
435
+ }
436
+ }
437
+
438
+ export const isPageRoute = (it: any): it is PageRoute => {
439
+ return (
440
+ it &&
441
+ typeof it === "object" &&
442
+ typeof it.path === "string" &&
443
+ typeof it.page === "object"
444
+ );
445
+ };
446
+
447
+ export interface PageRouteEntry
448
+ extends Omit<PageDescriptorOptions, "children" | "parent"> {
449
+ children?: PageRouteEntry[];
450
+ }
451
+
452
+ export interface PageRoute extends PageRouteEntry {
453
+ type: "page";
454
+ name: string;
455
+ parent?: PageRoute;
456
+ match: string;
457
+ }
458
+
459
+ export interface Layer {
460
+ config?: {
461
+ query?: Record<string, any>;
462
+ params?: Record<string, any>;
463
+ // stack of resolved props
464
+ context?: Record<string, any>;
465
+ };
466
+
467
+ name: string;
468
+ props?: Record<string, any>;
469
+ error?: Error;
470
+ part?: string;
471
+ element: ReactNode;
472
+ index: number;
473
+ path: string;
474
+ }
475
+
476
+ export type PreviousLayerData = Omit<Layer, "element">;
477
+
478
+ export interface AnchorProps {
479
+ href?: string;
480
+ onClick?: (ev: any) => any;
481
+ }
482
+
483
+ export interface RouterEvents {
484
+ begin: undefined;
485
+ success: undefined;
486
+ error: Error;
487
+ end: RouterState;
488
+ }
489
+
490
+ export interface RouterState {
491
+ pathname: string;
492
+ search: string;
493
+ layers: Array<Layer>;
494
+ head: Head;
495
+ }
496
+
497
+ export interface TransitionOptions {
498
+ state?: RouterState;
499
+ previous?: PreviousLayerData[];
500
+ context?: PageReactContext;
501
+ }
502
+
503
+ export interface RouterStackItem {
504
+ route: PageRoute;
505
+ config?: Record<string, any>;
506
+ props?: Record<string, any>;
507
+ error?: Error;
508
+ }
509
+
510
+ export interface RouterRenderResult {
511
+ redirect?: string;
512
+ layers: Layer[];
513
+ head: Head;
514
+ element: ReactNode;
515
+ }
516
+
517
+ export interface PageRequest extends PageReactContext {
518
+ url: URL;
519
+ params: Record<string, any>;
520
+ query: Record<string, string>;
521
+ head: Head;
522
+
523
+ // previous layers (browser history or browser hydration, always null on server)
524
+ previous?: PreviousLayerData[];
525
+ }
526
+
527
+ export interface CreateLayersResult extends RouterState {
528
+ redirect?: string;
529
+ }
530
+
531
+ // will be passed to ReactContext
532
+ export interface PageReactContext {
533
+ links?: HttpClientLink[];
534
+ }