@alepha/react 0.6.3 → 0.6.5

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