@alepha/react 0.7.7 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,153 @@
1
- # alepha/react
1
+ # Alepha React
2
+
3
+ Build server-side rendered (SSR) or single-page React applications.
4
+
5
+ ## Installation
6
+
7
+ This package is part of the Alepha framework and can be installed via the all-in-one package:
8
+
9
+ ```bash
10
+ npm install alepha
11
+ ```
12
+
13
+ Alternatively, you can install it individually:
14
+
15
+ ```bash
16
+ npm install @alepha/core @alepha/react
17
+ ```
18
+ ## Module
19
+
20
+ Provides full-stack React development with declarative routing, server-side rendering, and client-side hydration.
21
+
22
+ The React module enables building modern React applications using the `$page` descriptor on class properties.
23
+ It delivers seamless server-side rendering, automatic code splitting, and client-side navigation with full
24
+ type safety and schema validation for route parameters and data.
25
+
26
+ **Key Features:**
27
+ - Declarative page definition with `$page` descriptor
28
+ - Server-side rendering (SSR) with automatic hydration
29
+ - Type-safe routing with parameter validation
30
+ - Schema-based data resolution and validation
31
+ - SEO-friendly meta tag management
32
+ - Automatic code splitting and lazy loading
33
+ - Client-side navigation with browser history
34
+
35
+ **Basic Usage:**
36
+ ```ts
37
+ import { Alepha, run, t } from "alepha";
38
+ import { AlephaReact, $page } from "alepha/react";
39
+
40
+ class AppRoutes {
41
+ // Home page
42
+ home = $page({
43
+ path: "/",
44
+ component: () => (
45
+ <div>
46
+ <h1>Welcome to Alepha</h1>
47
+ <p>Build amazing React applications!</p>
48
+ </div>
49
+ ),
50
+ });
51
+
52
+ // About page with meta tags
53
+ about = $page({
54
+ path: "/about",
55
+ head: {
56
+ title: "About Us",
57
+ description: "Learn more about our mission",
58
+ },
59
+ component: () => (
60
+ <div>
61
+ <h1>About Us</h1>
62
+ <p>Learn more about our mission.</p>
63
+ </div>
64
+ ),
65
+ });
66
+ }
67
+
68
+ const alepha = Alepha.create()
69
+ .with(AlephaReact)
70
+ .with(AppRoutes);
71
+
72
+ run(alepha);
73
+ ```
74
+
75
+ **Dynamic Routes with Parameters:**
76
+ ```tsx
77
+ class UserRoutes {
78
+ userProfile = $page({
79
+ path: "/users/:id",
80
+ schema: {
81
+ params: t.object({
82
+ id: t.string(),
83
+ }),
84
+ },
85
+ resolve: async ({ params }) => {
86
+ // Fetch user data server-side
87
+ const user = await getUserById(params.id);
88
+ return { user };
89
+ },
90
+ head: ({ user }) => ({
91
+ title: `${user.name} - Profile`,
92
+ description: `View ${user.name}'s profile`,
93
+ }),
94
+ component: ({ user }) => (
95
+ <div>
96
+ <h1>{user.name}</h1>
97
+ <p>Email: {user.email}</p>
98
+ </div>
99
+ ),
100
+ });
101
+
102
+ userSettings = $page({
103
+ path: "/users/:id/settings",
104
+ schema: {
105
+ params: t.object({
106
+ id: t.string(),
107
+ }),
108
+ },
109
+ component: ({ params }) => (
110
+ <UserSettings userId={params.id} />
111
+ ),
112
+ });
113
+ }
114
+ ```
115
+
116
+ **Static Generation:**
117
+ ```tsx
118
+ class BlogRoutes {
119
+ blogPost = $page({
120
+ path: "/blog/:slug",
121
+ schema: {
122
+ params: t.object({
123
+ slug: t.string(),
124
+ }),
125
+ },
126
+ static: {
127
+ entries: [
128
+ { params: { slug: "getting-started" } },
129
+ { params: { slug: "advanced-features" } },
130
+ { params: { slug: "deployment" } },
131
+ ],
132
+ },
133
+ resolve: ({ params }) => {
134
+ const post = getBlogPost(params.slug);
135
+ return { post };
136
+ },
137
+ component: ({ post }) => (
138
+ <article>
139
+ <h1>{post.title}</h1>
140
+ <div>{post.content}</div>
141
+ </article>
142
+ ),
143
+ });
144
+ }
145
+ ```
146
+
147
+ ## API Reference
148
+
149
+ ### Descriptors
150
+
151
+ #### $page()
152
+
153
+ Main descriptor for defining a React route in the application.
@@ -1,8 +1,9 @@
1
1
  import { $hook, $inject, $logger, Alepha, KIND, NotImplementedError, OPTIONS, __bind, __descriptor, t } from "@alepha/core";
2
+ import { AlephaServer } from "@alepha/server";
3
+ import { AlephaServerLinks, LinkProvider } from "@alepha/server-links";
2
4
  import { RouterProvider } from "@alepha/router";
3
5
  import React, { StrictMode, createContext, createElement, useContext, useEffect, useMemo, useState } from "react";
4
6
  import { jsx, jsxs } from "react/jsx-runtime";
5
- import { LinkProvider } from "@alepha/server-links";
6
7
  import { createRoot, hydrateRoot } from "react-dom/client";
7
8
 
8
9
  //#region src/descriptors/$page.ts
@@ -12,11 +13,6 @@ const KEY = "PAGE";
12
13
  */
13
14
  const $page = (options) => {
14
15
  __descriptor(KEY);
15
- if (options.children) for (const child of options.children) child[OPTIONS].parent = { [OPTIONS]: options };
16
- if (options.parent) {
17
- options.parent[OPTIONS].children ??= [];
18
- options.parent[OPTIONS].children.push({ [OPTIONS]: options });
19
- }
20
16
  return {
21
17
  [KIND]: KEY,
22
18
  [OPTIONS]: options,
@@ -315,7 +311,7 @@ const NestedView = (props) => {
315
311
  const index = layer?.index ?? 0;
316
312
  const [view, setView] = useState(app?.state.layers[index]?.element);
317
313
  useRouterEvents({ onEnd: ({ state }) => {
318
- setView(state.layers[index]?.element);
314
+ if (!state.layers[index]?.cache) setView(state.layers[index]?.element);
319
315
  } }, [app]);
320
316
  if (!app) throw new Error("NestedView must be used within a RouterContext.");
321
317
  const element = view ?? props.children ?? null;
@@ -389,19 +385,18 @@ var PageDescriptorProvider = class {
389
385
  const route$1 = it.route;
390
386
  const config = {};
391
387
  try {
392
- config.query = route$1.schema?.query ? this.alepha.parse(route$1.schema.query, request.query) : request.query;
388
+ config.query = route$1.schema?.query ? this.alepha.parse(route$1.schema.query, request.query) : {};
393
389
  } catch (e) {
394
390
  it.error = e;
395
391
  break;
396
392
  }
397
393
  try {
398
- config.params = route$1.schema?.params ? this.alepha.parse(route$1.schema.params, request.params) : request.params;
394
+ config.params = route$1.schema?.params ? this.alepha.parse(route$1.schema.params, request.params) : {};
399
395
  } catch (e) {
400
396
  it.error = e;
401
397
  break;
402
398
  }
403
399
  it.config = { ...config };
404
- if (!route$1.resolve) continue;
405
400
  const previous = request.previous;
406
401
  if (previous?.[i] && !forceRefresh && previous[i].name === route$1.name) {
407
402
  const url = (str) => str ? str.replace(/\/\/+/g, "/") : "/";
@@ -416,6 +411,7 @@ var PageDescriptorProvider = class {
416
411
  if (prev === curr) {
417
412
  it.props = previous[i].props;
418
413
  it.error = previous[i].error;
414
+ it.cache = true;
419
415
  context = {
420
416
  ...context,
421
417
  ...it.props
@@ -424,6 +420,7 @@ var PageDescriptorProvider = class {
424
420
  }
425
421
  forceRefresh = true;
426
422
  }
423
+ if (!route$1.resolve) continue;
427
424
  try {
428
425
  const props = await route$1.resolve?.({
429
426
  ...request,
@@ -470,7 +467,7 @@ var PageDescriptorProvider = class {
470
467
  element: this.renderView(i + 1, path, element$1, it.route),
471
468
  index: i + 1,
472
469
  path,
473
- route
470
+ route: it.route
474
471
  });
475
472
  break;
476
473
  }
@@ -486,7 +483,8 @@ var PageDescriptorProvider = class {
486
483
  element: this.renderView(i + 1, path, element, it.route),
487
484
  index: i + 1,
488
485
  path,
489
- route
486
+ route: it.route,
487
+ cache: it.cache
490
488
  });
491
489
  }
492
490
  return {
@@ -542,14 +540,20 @@ var PageDescriptorProvider = class {
542
540
  } }, element);
543
541
  }
544
542
  configure = $hook({
545
- name: "configure",
543
+ on: "configure",
546
544
  handler: () => {
547
545
  let hasNotFoundHandler = false;
548
546
  const pages = this.alepha.getDescriptorValues($page);
547
+ const hasParent = (it) => {
548
+ for (const page of pages) {
549
+ const children = page.value[OPTIONS].children ? Array.isArray(page.value[OPTIONS].children) ? page.value[OPTIONS].children : page.value[OPTIONS].children() : [];
550
+ if (children.includes(it)) return true;
551
+ }
552
+ };
549
553
  for (const { value, key } of pages) value[OPTIONS].name ??= key;
550
554
  for (const { value } of pages) {
551
- if (value[OPTIONS].parent) continue;
552
555
  if (value[OPTIONS].path === "/*") hasNotFoundHandler = true;
556
+ if (hasParent(value)) continue;
553
557
  this.add(this.map(pages, value));
554
558
  }
555
559
  if (!hasNotFoundHandler && pages.length > 0) this.add({
@@ -564,7 +568,7 @@ var PageDescriptorProvider = class {
564
568
  }
565
569
  });
566
570
  map(pages, target) {
567
- const children = target[OPTIONS].children ?? [];
571
+ const children = target[OPTIONS].children ? Array.isArray(target[OPTIONS].children) ? target[OPTIONS].children : target[OPTIONS].children() : [];
568
572
  return {
569
573
  ...target[OPTIONS],
570
574
  parent: void 0,
@@ -613,7 +617,7 @@ var BrowserRouterProvider = class extends RouterProvider {
613
617
  this.pageDescriptorProvider.add(entry);
614
618
  }
615
619
  configure = $hook({
616
- name: "configure",
620
+ on: "configure",
617
621
  handler: async () => {
618
622
  for (const page of this.pageDescriptorProvider.getPages()) if (page.component || page.lazy) this.push({
619
623
  path: page.match,
@@ -719,8 +723,22 @@ var ReactBrowserProvider = class {
719
723
  get history() {
720
724
  return window.history;
721
725
  }
726
+ get location() {
727
+ return window.location;
728
+ }
722
729
  get url() {
723
- return window.location.pathname + window.location.search;
730
+ let url = this.location.pathname + this.location.search;
731
+ if (import.meta?.env?.BASE_URL) {
732
+ url = url.replace(import.meta.env?.BASE_URL, "");
733
+ if (!url.startsWith("/")) url = `/${url}`;
734
+ }
735
+ return url;
736
+ }
737
+ pushState(url, replace) {
738
+ let path = url;
739
+ if (import.meta?.env?.BASE_URL) path = (import.meta.env?.BASE_URL + path).replaceAll("//", "/");
740
+ if (replace) this.history.replaceState({}, "", path);
741
+ else this.history.pushState({}, "", path);
724
742
  }
725
743
  async invalidate(props) {
726
744
  const previous = [];
@@ -746,14 +764,14 @@ var ReactBrowserProvider = class {
746
764
  async go(url, options = {}) {
747
765
  const result = await this.render({ url });
748
766
  if (result.context.url.pathname !== url) {
749
- this.history.replaceState({}, "", result.context.url.pathname);
767
+ this.pushState(result.context.url.pathname);
750
768
  return;
751
769
  }
752
770
  if (options.replace) {
753
- this.history.replaceState({}, "", url);
771
+ this.pushState(url);
754
772
  return;
755
773
  }
756
- this.history.pushState({}, "", url);
774
+ this.pushState(url);
757
775
  }
758
776
  async render(options = {}) {
759
777
  const previous = options.previous ?? this.state.layers;
@@ -778,7 +796,7 @@ var ReactBrowserProvider = class {
778
796
  }
779
797
  }
780
798
  ready = $hook({
781
- name: "ready",
799
+ on: "ready",
782
800
  handler: async () => {
783
801
  const hydration = this.getHydrationState();
784
802
  const previous = hydration?.layers ?? [];
@@ -790,6 +808,7 @@ var ReactBrowserProvider = class {
790
808
  hydration
791
809
  });
792
810
  window.addEventListener("popstate", () => {
811
+ if (this.state.pathname === location.pathname) return;
793
812
  this.render();
794
813
  });
795
814
  }
@@ -805,6 +824,7 @@ var ReactBrowserRenderer = class {
805
824
  env = $inject(envSchema);
806
825
  log = $logger();
807
826
  root;
827
+ options = { scrollRestoration: "top" };
808
828
  getRootElement() {
809
829
  const root = this.browserProvider.document.getElementById(this.env.REACT_ROOT_ID);
810
830
  if (root) return root;
@@ -814,7 +834,7 @@ var ReactBrowserRenderer = class {
814
834
  return div;
815
835
  }
816
836
  ready = $hook({
817
- name: "react:browser:render",
837
+ on: "react:browser:render",
818
838
  handler: async ({ state, context, hydration }) => {
819
839
  const element = this.browserRouterProvider.root(state, context);
820
840
  if (hydration?.layers) {
@@ -827,17 +847,32 @@ var ReactBrowserRenderer = class {
827
847
  }
828
848
  }
829
849
  });
850
+ onTransitionEnd = $hook({
851
+ on: "react:transition:end",
852
+ handler: () => {
853
+ if (this.options.scrollRestoration === "top" && typeof window !== "undefined") window.scrollTo(0, 0);
854
+ }
855
+ });
830
856
  };
831
857
 
832
858
  //#endregion
833
859
  //#region src/hooks/RouterHookApi.ts
834
860
  var RouterHookApi = class {
835
- constructor(pages, state, layer, browser) {
861
+ constructor(pages, context, state, layer, browser) {
836
862
  this.pages = pages;
863
+ this.context = context;
837
864
  this.state = state;
838
865
  this.layer = layer;
839
866
  this.browser = browser;
840
867
  }
868
+ getURL() {
869
+ if (!this.browser) return this.context.url;
870
+ return new URL(this.location.href);
871
+ }
872
+ get location() {
873
+ if (!this.browser) throw new Error("Browser is required");
874
+ return this.browser.location;
875
+ }
841
876
  get current() {
842
877
  return this.state;
843
878
  }
@@ -915,7 +950,7 @@ const useRouter = () => {
915
950
  const pages = useMemo(() => {
916
951
  return ctx.alepha.get(PageDescriptorProvider).getPages();
917
952
  }, []);
918
- return useMemo(() => new RouterHookApi(pages, ctx.state, layer, ctx.alepha.isBrowser() ? ctx.alepha.get(ReactBrowserProvider) : void 0), [layer]);
953
+ return useMemo(() => new RouterHookApi(pages, ctx.context, ctx.state, layer, ctx.alepha.isBrowser() ? ctx.alepha.get(ReactBrowserProvider) : void 0), [layer]);
919
954
  };
920
955
 
921
956
  //#endregion
@@ -1036,10 +1071,10 @@ const useRouterState = () => {
1036
1071
  //#region src/index.browser.ts
1037
1072
  var AlephaReact = class {
1038
1073
  name = "alepha.react";
1039
- $services = (alepha) => alepha.with(PageDescriptorProvider).with(ReactBrowserProvider).with(BrowserRouterProvider).with(ReactBrowserRenderer);
1074
+ $services = (alepha) => alepha.with(AlephaServer).with(AlephaServerLinks).with(PageDescriptorProvider).with(ReactBrowserProvider).with(BrowserRouterProvider).with(ReactBrowserRenderer);
1040
1075
  };
1041
1076
  __bind($page, AlephaReact);
1042
1077
 
1043
1078
  //#endregion
1044
- export { $page, AlephaReact, BrowserRouterProvider, ClientOnly_default as ClientOnly, ErrorBoundary_default as ErrorBoundary, Link_default as Link, NestedView_default as NestedView, PageDescriptorProvider, ReactBrowserProvider, RedirectionError, RouterContext, RouterHookApi, RouterLayerContext, isPageRoute, useActive, useAlepha, useClient, useInject, useQueryParams, useRouter, useRouterEvents, useRouterState };
1079
+ export { $page, AlephaReact, BrowserRouterProvider, ClientOnly_default as ClientOnly, ErrorBoundary_default as ErrorBoundary, Link_default as Link, NestedView_default as NestedView, NotFoundPage as NotFound, PageDescriptorProvider, ReactBrowserProvider, RedirectionError, RouterContext, RouterHookApi, RouterLayerContext, isPageRoute, useActive, useAlepha, useClient, useInject, useQueryParams, useRouter, useRouterEvents, useRouterState };
1045
1080
  //# sourceMappingURL=index.browser.js.map