@alepha/react 0.11.11 → 0.12.0
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 +1 -183
- package/dist/auth/index.browser.js +1460 -0
- package/dist/auth/index.browser.js.map +1 -0
- package/dist/auth/index.cjs +3647 -0
- package/dist/auth/index.cjs.map +1 -0
- package/dist/auth/index.d.cts +564 -0
- package/dist/auth/index.d.cts.map +1 -0
- package/dist/auth/index.d.ts +564 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +3615 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/{index.browser.js → core/index.browser.js} +36 -35
- package/dist/core/index.browser.js.map +1 -0
- package/dist/{index.cjs → core/index.cjs} +141 -140
- package/dist/core/index.cjs.map +1 -0
- package/dist/{index.d.cts → core/index.d.cts} +68 -68
- package/dist/core/index.d.cts.map +1 -0
- package/dist/{index.d.ts → core/index.d.ts} +68 -68
- package/dist/core/index.d.ts.map +1 -0
- package/dist/{index.js → core/index.js} +39 -38
- package/dist/core/index.js.map +1 -0
- package/dist/form/index.cjs +2054 -0
- package/dist/form/index.cjs.map +1 -0
- package/dist/form/index.d.cts +211 -0
- package/dist/form/index.d.cts.map +1 -0
- package/dist/form/index.d.ts +211 -0
- package/dist/form/index.d.ts.map +1 -0
- package/dist/form/index.js +2026 -0
- package/dist/form/index.js.map +1 -0
- package/dist/head/index.browser.js +1503 -0
- package/dist/head/index.browser.js.map +1 -0
- package/dist/head/index.cjs +1908 -0
- package/dist/head/index.cjs.map +1 -0
- package/dist/head/index.d.cts +595 -0
- package/dist/head/index.d.cts.map +1 -0
- package/dist/head/index.d.ts +601 -0
- package/dist/head/index.d.ts.map +1 -0
- package/dist/head/index.js +1880 -0
- package/dist/head/index.js.map +1 -0
- package/dist/i18n/index.cjs +1886 -0
- package/dist/i18n/index.cjs.map +1 -0
- package/dist/i18n/index.d.cts +168 -0
- package/dist/i18n/index.d.cts.map +1 -0
- package/dist/i18n/index.d.ts +168 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +1857 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/websocket/index.cjs +1774 -0
- package/dist/websocket/index.cjs.map +1 -0
- package/dist/websocket/index.d.cts +118 -0
- package/dist/websocket/index.d.cts.map +1 -0
- package/dist/websocket/index.d.ts +118 -0
- package/dist/websocket/index.d.ts.map +1 -0
- package/dist/websocket/index.js +1750 -0
- package/dist/websocket/index.js.map +1 -0
- package/package.json +89 -67
- package/src/auth/descriptors/$auth.ts +436 -0
- package/src/auth/descriptors/$authApple.ts +8 -0
- package/src/auth/descriptors/$authGithub.ts +81 -0
- package/src/auth/descriptors/$authGoogle.ts +38 -0
- package/src/auth/errors/SessionExpiredError.ts +6 -0
- package/src/auth/hooks/useAuth.ts +31 -0
- package/src/auth/index.browser.ts +16 -0
- package/src/auth/index.shared.ts +3 -0
- package/src/auth/index.ts +47 -0
- package/src/auth/providers/ReactAuthProvider.ts +629 -0
- package/src/auth/schemas/tokenResponseSchema.ts +11 -0
- package/src/auth/schemas/tokensSchema.ts +21 -0
- package/src/auth/schemas/userinfoResponseSchema.ts +10 -0
- package/src/auth/services/ReactAuth.ts +124 -0
- package/src/{components → core/components}/ErrorViewer.tsx +3 -2
- package/src/{components → core/components}/NestedView.tsx +1 -1
- package/src/{contexts → core/contexts}/AlephaContext.ts +1 -1
- package/src/{descriptors → core/descriptors}/$page.ts +4 -4
- package/src/{hooks → core/hooks}/useAction.ts +1 -1
- package/src/{hooks → core/hooks}/useAlepha.ts +1 -1
- package/src/{hooks → core/hooks}/useClient.ts +1 -1
- package/src/{hooks → core/hooks}/useEvents.ts +1 -1
- package/src/{hooks → core/hooks}/useInject.ts +1 -1
- package/src/{hooks → core/hooks}/useQueryParams.ts +1 -1
- package/src/{hooks → core/hooks}/useRouterState.ts +1 -1
- package/src/{hooks → core/hooks}/useSchema.ts +3 -3
- package/src/{hooks → core/hooks}/useStore.ts +2 -2
- package/src/{index.browser.ts → core/index.browser.ts} +4 -4
- package/src/{index.ts → core/index.ts} +6 -6
- package/src/{providers → core/providers}/ReactBrowserProvider.ts +6 -6
- package/src/{providers → core/providers}/ReactBrowserRendererProvider.ts +2 -2
- package/src/{providers → core/providers}/ReactBrowserRouterProvider.ts +3 -3
- package/src/{providers → core/providers}/ReactPageProvider.ts +3 -3
- package/src/{providers → core/providers}/ReactServerProvider.ts +7 -7
- package/src/{services → core/services}/ReactPageServerService.ts +2 -2
- package/src/{services → core/services}/ReactPageService.ts +1 -1
- package/src/{services → core/services}/ReactRouter.ts +1 -1
- package/src/form/components/FormState.tsx +17 -0
- package/src/form/hooks/useForm.ts +47 -0
- package/src/form/hooks/useFormState.ts +130 -0
- package/src/form/index.ts +38 -0
- package/src/form/services/FormModel.ts +548 -0
- package/src/head/descriptors/$head.ts +25 -0
- package/src/head/hooks/useHead.ts +62 -0
- package/src/head/index.browser.ts +25 -0
- package/src/head/index.ts +47 -0
- package/src/head/interfaces/Head.ts +46 -0
- package/src/head/providers/BrowserHeadProvider.ts +105 -0
- package/src/head/providers/HeadProvider.ts +73 -0
- package/src/head/providers/ServerHeadProvider.ts +109 -0
- package/src/i18n/README.md +76 -0
- package/src/i18n/components/Localize.tsx +35 -0
- package/src/i18n/descriptors/$dictionary.ts +65 -0
- package/src/i18n/hooks/useI18n.ts +18 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/providers/I18nProvider.ts +277 -0
- package/src/websocket/hooks/useRoom.tsx +223 -0
- package/src/websocket/index.ts +7 -0
- package/dist/index.browser.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- /package/src/{components → core/components}/ClientOnly.tsx +0 -0
- /package/src/{components → core/components}/ErrorBoundary.tsx +0 -0
- /package/src/{components → core/components}/Link.tsx +0 -0
- /package/src/{components → core/components}/NotFound.tsx +0 -0
- /package/src/{contexts → core/contexts}/RouterLayerContext.ts +0 -0
- /package/src/{errors → core/errors}/Redirection.ts +0 -0
- /package/src/{hooks → core/hooks}/useActive.ts +0 -0
- /package/src/{hooks → core/hooks}/useRouter.ts +0 -0
- /package/src/{index.shared.ts → core/index.shared.ts} +0 -0
|
@@ -0,0 +1,2054 @@
|
|
|
1
|
+
//#region rolldown:runtime
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
10
|
+
key = keys[i];
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
12
|
+
get: ((k) => from[k]).bind(null, key),
|
|
13
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
19
|
+
value: mod,
|
|
20
|
+
enumerable: true
|
|
21
|
+
}) : target, mod));
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
let alepha = require("alepha");
|
|
25
|
+
let alepha_datetime = require("alepha/datetime");
|
|
26
|
+
let alepha_server = require("alepha/server");
|
|
27
|
+
let alepha_server_cache = require("alepha/server/cache");
|
|
28
|
+
let alepha_server_links = require("alepha/server/links");
|
|
29
|
+
let alepha_logger = require("alepha/logger");
|
|
30
|
+
let react = require("react");
|
|
31
|
+
react = __toESM(react);
|
|
32
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
33
|
+
let node_fs = require("node:fs");
|
|
34
|
+
let node_path = require("node:path");
|
|
35
|
+
let alepha_server_static = require("alepha/server/static");
|
|
36
|
+
let react_dom_server = require("react-dom/server");
|
|
37
|
+
let alepha_router = require("alepha/router");
|
|
38
|
+
|
|
39
|
+
//#region src/core/services/ReactPageService.ts
|
|
40
|
+
var ReactPageService = class {
|
|
41
|
+
fetch(pathname, options = {}) {
|
|
42
|
+
throw new alepha.AlephaError("Fetch is not available for this environment.");
|
|
43
|
+
}
|
|
44
|
+
render(name, options = {}) {
|
|
45
|
+
throw new alepha.AlephaError("Render is not available for this environment.");
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/core/descriptors/$page.ts
|
|
51
|
+
/**
|
|
52
|
+
* Main descriptor for defining a React route in the application.
|
|
53
|
+
*
|
|
54
|
+
* The $page descriptor is the core building block for creating type-safe, SSR-enabled React routes.
|
|
55
|
+
* It provides a declarative way to define pages with powerful features:
|
|
56
|
+
*
|
|
57
|
+
* **Routing & Navigation**
|
|
58
|
+
* - URL pattern matching with parameters (e.g., `/users/:id`)
|
|
59
|
+
* - Nested routing with parent-child relationships
|
|
60
|
+
* - Type-safe URL parameter and query string validation
|
|
61
|
+
*
|
|
62
|
+
* **Data Loading**
|
|
63
|
+
* - Server-side data fetching with the `resolve` function
|
|
64
|
+
* - Automatic serialization and hydration for SSR
|
|
65
|
+
* - Access to request context, URL params, and parent data
|
|
66
|
+
*
|
|
67
|
+
* **Component Loading**
|
|
68
|
+
* - Direct component rendering or lazy loading for code splitting
|
|
69
|
+
* - Client-only rendering when browser APIs are needed
|
|
70
|
+
* - Automatic fallback handling during hydration
|
|
71
|
+
*
|
|
72
|
+
* **Performance Optimization**
|
|
73
|
+
* - Static generation for pre-rendered pages at build time
|
|
74
|
+
* - Server-side caching with configurable TTL and providers
|
|
75
|
+
* - Code splitting through lazy component loading
|
|
76
|
+
*
|
|
77
|
+
* **Error Handling**
|
|
78
|
+
* - Custom error handlers with support for redirects
|
|
79
|
+
* - Hierarchical error handling (child → parent)
|
|
80
|
+
* - HTTP status code handling (404, 401, etc.)
|
|
81
|
+
*
|
|
82
|
+
* **Page Animations**
|
|
83
|
+
* - CSS-based enter/exit animations
|
|
84
|
+
* - Dynamic animations based on page state
|
|
85
|
+
* - Custom timing and easing functions
|
|
86
|
+
*
|
|
87
|
+
* **Lifecycle Management**
|
|
88
|
+
* - Server response hooks for headers and status codes
|
|
89
|
+
* - Page leave handlers for cleanup (browser only)
|
|
90
|
+
* - Permission-based access control
|
|
91
|
+
*
|
|
92
|
+
* @example Simple page with data fetching
|
|
93
|
+
* ```typescript
|
|
94
|
+
* const userProfile = $page({
|
|
95
|
+
* path: "/users/:id",
|
|
96
|
+
* schema: {
|
|
97
|
+
* params: t.object({ id: t.int() }),
|
|
98
|
+
* query: t.object({ tab: t.optional(t.text()) })
|
|
99
|
+
* },
|
|
100
|
+
* resolve: async ({ params }) => {
|
|
101
|
+
* const user = await userApi.getUser(params.id);
|
|
102
|
+
* return { user };
|
|
103
|
+
* },
|
|
104
|
+
* lazy: () => import("./UserProfile.tsx")
|
|
105
|
+
* });
|
|
106
|
+
* ```
|
|
107
|
+
*
|
|
108
|
+
* @example Nested routing with error handling
|
|
109
|
+
* ```typescript
|
|
110
|
+
* const projectSection = $page({
|
|
111
|
+
* path: "/projects/:id",
|
|
112
|
+
* children: () => [projectBoard, projectSettings],
|
|
113
|
+
* resolve: async ({ params }) => {
|
|
114
|
+
* const project = await projectApi.get(params.id);
|
|
115
|
+
* return { project };
|
|
116
|
+
* },
|
|
117
|
+
* errorHandler: (error) => {
|
|
118
|
+
* if (HttpError.is(error, 404)) {
|
|
119
|
+
* return <ProjectNotFound />;
|
|
120
|
+
* }
|
|
121
|
+
* }
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* @example Static generation with caching
|
|
126
|
+
* ```typescript
|
|
127
|
+
* const blogPost = $page({
|
|
128
|
+
* path: "/blog/:slug",
|
|
129
|
+
* static: {
|
|
130
|
+
* entries: posts.map(p => ({ params: { slug: p.slug } }))
|
|
131
|
+
* },
|
|
132
|
+
* resolve: async ({ params }) => {
|
|
133
|
+
* const post = await loadPost(params.slug);
|
|
134
|
+
* return { post };
|
|
135
|
+
* }
|
|
136
|
+
* });
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
const $page = (options) => {
|
|
140
|
+
return (0, alepha.createDescriptor)(PageDescriptor, options);
|
|
141
|
+
};
|
|
142
|
+
var PageDescriptor = class extends alepha.Descriptor {
|
|
143
|
+
reactPageService = (0, alepha.$inject)(ReactPageService);
|
|
144
|
+
onInit() {
|
|
145
|
+
if (this.options.static) this.options.cache ??= { store: {
|
|
146
|
+
provider: "memory",
|
|
147
|
+
ttl: [1, "week"]
|
|
148
|
+
} };
|
|
149
|
+
}
|
|
150
|
+
get name() {
|
|
151
|
+
return this.options.name ?? this.config.propertyKey;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* For testing or build purposes.
|
|
155
|
+
*
|
|
156
|
+
* This will render the page (HTML layout included or not) and return the HTML + context.
|
|
157
|
+
* Only valid for server-side rendering, it will throw an error if called on the client-side.
|
|
158
|
+
*/
|
|
159
|
+
async render(options) {
|
|
160
|
+
return this.reactPageService.render(this.name, options);
|
|
161
|
+
}
|
|
162
|
+
async fetch(options) {
|
|
163
|
+
return this.reactPageService.fetch(this.options.path || "", options);
|
|
164
|
+
}
|
|
165
|
+
match(url) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
pathname(config) {
|
|
169
|
+
return this.options.path || "";
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
$page[alepha.KIND] = PageDescriptor;
|
|
173
|
+
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/core/components/ClientOnly.tsx
|
|
176
|
+
/**
|
|
177
|
+
* A small utility component that renders its children only on the client side.
|
|
178
|
+
*
|
|
179
|
+
* Optionally, you can provide a fallback React node that will be rendered.
|
|
180
|
+
*
|
|
181
|
+
* You should use this component when
|
|
182
|
+
* - you have code that relies on browser-specific APIs
|
|
183
|
+
* - you want to avoid server-side rendering for a specific part of your application
|
|
184
|
+
* - you want to prevent pre-rendering of a component
|
|
185
|
+
*/
|
|
186
|
+
const ClientOnly = (props) => {
|
|
187
|
+
const [mounted, setMounted] = (0, react.useState)(false);
|
|
188
|
+
(0, react.useEffect)(() => setMounted(true), []);
|
|
189
|
+
if (props.disabled) return props.children;
|
|
190
|
+
return mounted ? props.children : props.fallback;
|
|
191
|
+
};
|
|
192
|
+
var ClientOnly_default = ClientOnly;
|
|
193
|
+
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/core/components/ErrorViewer.tsx
|
|
196
|
+
const ErrorViewer = ({ error, alepha: alepha$1 }) => {
|
|
197
|
+
const [expanded, setExpanded] = (0, react.useState)(false);
|
|
198
|
+
if (alepha$1.isProduction()) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorViewerProduction, {});
|
|
199
|
+
const stackLines = error.stack?.split("\n") ?? [];
|
|
200
|
+
const previewLines = stackLines.slice(0, 5);
|
|
201
|
+
const hiddenLineCount = stackLines.length - previewLines.length;
|
|
202
|
+
const copyToClipboard = (text) => {
|
|
203
|
+
navigator.clipboard.writeText(text).catch((err) => {
|
|
204
|
+
console.error("Clipboard error:", err);
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
const styles = {
|
|
208
|
+
container: {
|
|
209
|
+
padding: "24px",
|
|
210
|
+
backgroundColor: "#FEF2F2",
|
|
211
|
+
color: "#7F1D1D",
|
|
212
|
+
border: "1px solid #FECACA",
|
|
213
|
+
borderRadius: "16px",
|
|
214
|
+
boxShadow: "0 8px 24px rgba(0,0,0,0.05)",
|
|
215
|
+
fontFamily: "monospace",
|
|
216
|
+
maxWidth: "768px",
|
|
217
|
+
margin: "40px auto"
|
|
218
|
+
},
|
|
219
|
+
heading: {
|
|
220
|
+
fontSize: "20px",
|
|
221
|
+
fontWeight: "bold",
|
|
222
|
+
marginBottom: "10px"
|
|
223
|
+
},
|
|
224
|
+
name: {
|
|
225
|
+
fontSize: "16px",
|
|
226
|
+
fontWeight: 600
|
|
227
|
+
},
|
|
228
|
+
message: {
|
|
229
|
+
fontSize: "14px",
|
|
230
|
+
marginBottom: "16px"
|
|
231
|
+
},
|
|
232
|
+
sectionHeader: {
|
|
233
|
+
display: "flex",
|
|
234
|
+
justifyContent: "space-between",
|
|
235
|
+
alignItems: "center",
|
|
236
|
+
fontSize: "12px",
|
|
237
|
+
marginBottom: "4px",
|
|
238
|
+
color: "#991B1B"
|
|
239
|
+
},
|
|
240
|
+
copyButton: {
|
|
241
|
+
fontSize: "12px",
|
|
242
|
+
color: "#DC2626",
|
|
243
|
+
background: "none",
|
|
244
|
+
border: "none",
|
|
245
|
+
cursor: "pointer",
|
|
246
|
+
textDecoration: "underline"
|
|
247
|
+
},
|
|
248
|
+
stackContainer: {
|
|
249
|
+
backgroundColor: "#FEE2E2",
|
|
250
|
+
padding: "12px",
|
|
251
|
+
borderRadius: "8px",
|
|
252
|
+
fontSize: "13px",
|
|
253
|
+
lineHeight: "1.4",
|
|
254
|
+
overflowX: "auto",
|
|
255
|
+
whiteSpace: "pre-wrap"
|
|
256
|
+
},
|
|
257
|
+
expandLine: {
|
|
258
|
+
color: "#F87171",
|
|
259
|
+
cursor: "pointer",
|
|
260
|
+
marginTop: "8px"
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
264
|
+
style: styles.container,
|
|
265
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
|
|
266
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
267
|
+
style: styles.heading,
|
|
268
|
+
children: "🔥 Error"
|
|
269
|
+
}),
|
|
270
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
271
|
+
style: styles.name,
|
|
272
|
+
children: error.name
|
|
273
|
+
}),
|
|
274
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
275
|
+
style: styles.message,
|
|
276
|
+
children: error.message
|
|
277
|
+
})
|
|
278
|
+
] }), stackLines.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
279
|
+
style: styles.sectionHeader,
|
|
280
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "Stack trace" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
281
|
+
type: "button",
|
|
282
|
+
onClick: () => copyToClipboard(error.stack),
|
|
283
|
+
style: styles.copyButton,
|
|
284
|
+
children: "Copy all"
|
|
285
|
+
})]
|
|
286
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("pre", {
|
|
287
|
+
style: styles.stackContainer,
|
|
288
|
+
children: [(expanded ? stackLines : previewLines).map((line, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: line }, i)), !expanded && hiddenLineCount > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
289
|
+
style: styles.expandLine,
|
|
290
|
+
onClick: () => setExpanded(true),
|
|
291
|
+
children: [
|
|
292
|
+
"+ ",
|
|
293
|
+
hiddenLineCount,
|
|
294
|
+
" more lines..."
|
|
295
|
+
]
|
|
296
|
+
})]
|
|
297
|
+
})] })]
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
var ErrorViewer_default = ErrorViewer;
|
|
301
|
+
const ErrorViewerProduction = () => {
|
|
302
|
+
const styles = {
|
|
303
|
+
container: {
|
|
304
|
+
padding: "24px",
|
|
305
|
+
backgroundColor: "#FEF2F2",
|
|
306
|
+
color: "#7F1D1D",
|
|
307
|
+
border: "1px solid #FECACA",
|
|
308
|
+
borderRadius: "16px",
|
|
309
|
+
boxShadow: "0 8px 24px rgba(0,0,0,0.05)",
|
|
310
|
+
fontFamily: "monospace",
|
|
311
|
+
maxWidth: "768px",
|
|
312
|
+
margin: "40px auto",
|
|
313
|
+
textAlign: "center"
|
|
314
|
+
},
|
|
315
|
+
heading: {
|
|
316
|
+
fontSize: "20px",
|
|
317
|
+
fontWeight: "bold",
|
|
318
|
+
marginBottom: "8px"
|
|
319
|
+
},
|
|
320
|
+
name: {
|
|
321
|
+
fontSize: "16px",
|
|
322
|
+
fontWeight: 600,
|
|
323
|
+
marginBottom: "4px"
|
|
324
|
+
},
|
|
325
|
+
message: {
|
|
326
|
+
fontSize: "14px",
|
|
327
|
+
opacity: .85
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
331
|
+
style: styles.container,
|
|
332
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
333
|
+
style: styles.heading,
|
|
334
|
+
children: "🚨 An error occurred"
|
|
335
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
336
|
+
style: styles.message,
|
|
337
|
+
children: "Something went wrong. Please try again later."
|
|
338
|
+
})]
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
//#endregion
|
|
343
|
+
//#region src/core/contexts/RouterLayerContext.ts
|
|
344
|
+
const RouterLayerContext = (0, react.createContext)(void 0);
|
|
345
|
+
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region src/core/errors/Redirection.ts
|
|
348
|
+
/**
|
|
349
|
+
* Used for Redirection during the page loading.
|
|
350
|
+
*
|
|
351
|
+
* Depends on the context, it can be thrown or just returned.
|
|
352
|
+
*/
|
|
353
|
+
var Redirection = class extends Error {
|
|
354
|
+
redirect;
|
|
355
|
+
constructor(redirect) {
|
|
356
|
+
super("Redirection");
|
|
357
|
+
this.redirect = redirect;
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
//#endregion
|
|
362
|
+
//#region src/core/contexts/AlephaContext.ts
|
|
363
|
+
const AlephaContext = (0, react.createContext)(void 0);
|
|
364
|
+
|
|
365
|
+
//#endregion
|
|
366
|
+
//#region src/core/hooks/useAlepha.ts
|
|
367
|
+
/**
|
|
368
|
+
* Main Alepha hook.
|
|
369
|
+
*
|
|
370
|
+
* It provides access to the Alepha instance within a React component.
|
|
371
|
+
*
|
|
372
|
+
* With Alepha, you can access the core functionalities of the framework:
|
|
373
|
+
*
|
|
374
|
+
* - alepha.state() for state management
|
|
375
|
+
* - alepha.inject() for dependency injection
|
|
376
|
+
* - alepha.events.emit() for event handling
|
|
377
|
+
* etc...
|
|
378
|
+
*/
|
|
379
|
+
const useAlepha = () => {
|
|
380
|
+
const alepha$1 = (0, react.useContext)(AlephaContext);
|
|
381
|
+
if (!alepha$1) throw new alepha.AlephaError("Hook 'useAlepha()' must be used within an AlephaContext.Provider");
|
|
382
|
+
return alepha$1;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
//#endregion
|
|
386
|
+
//#region src/core/hooks/useEvents.ts
|
|
387
|
+
/**
|
|
388
|
+
* Allow subscribing to multiple Alepha events. See {@link Hooks} for available events.
|
|
389
|
+
*
|
|
390
|
+
* useEvents is fully typed to ensure correct event callback signatures.
|
|
391
|
+
*
|
|
392
|
+
* @example
|
|
393
|
+
* ```tsx
|
|
394
|
+
* useEvents(
|
|
395
|
+
* {
|
|
396
|
+
* "react:transition:begin": (ev) => {
|
|
397
|
+
* console.log("Transition began to:", ev.to);
|
|
398
|
+
* },
|
|
399
|
+
* "react:transition:error": {
|
|
400
|
+
* priority: "first",
|
|
401
|
+
* callback: (ev) => {
|
|
402
|
+
* console.error("Transition error:", ev.error);
|
|
403
|
+
* },
|
|
404
|
+
* },
|
|
405
|
+
* },
|
|
406
|
+
* [],
|
|
407
|
+
* );
|
|
408
|
+
* ```
|
|
409
|
+
*/
|
|
410
|
+
const useEvents = (opts, deps) => {
|
|
411
|
+
const alepha$1 = useAlepha();
|
|
412
|
+
(0, react.useEffect)(() => {
|
|
413
|
+
if (!alepha$1.isBrowser()) return;
|
|
414
|
+
const subs = [];
|
|
415
|
+
for (const [name, hook] of Object.entries(opts)) subs.push(alepha$1.events.on(name, hook));
|
|
416
|
+
return () => {
|
|
417
|
+
for (const clear of subs) clear();
|
|
418
|
+
};
|
|
419
|
+
}, deps);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
//#endregion
|
|
423
|
+
//#region src/core/hooks/useStore.ts
|
|
424
|
+
function useStore(target, defaultValue) {
|
|
425
|
+
const alepha$1 = useAlepha();
|
|
426
|
+
(0, react.useMemo)(() => {
|
|
427
|
+
if (defaultValue != null && alepha$1.state.get(target) == null) alepha$1.state.set(target, defaultValue);
|
|
428
|
+
}, [defaultValue]);
|
|
429
|
+
const [state, setState] = (0, react.useState)(alepha$1.state.get(target));
|
|
430
|
+
(0, react.useEffect)(() => {
|
|
431
|
+
if (!alepha$1.isBrowser()) return;
|
|
432
|
+
const key = target instanceof alepha.Atom ? target.key : target;
|
|
433
|
+
return alepha$1.events.on("state:mutate", (ev) => {
|
|
434
|
+
if (ev.key === key) setState(ev.value);
|
|
435
|
+
});
|
|
436
|
+
}, []);
|
|
437
|
+
return [state, (value) => {
|
|
438
|
+
alepha$1.state.set(target, value);
|
|
439
|
+
}];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region src/core/hooks/useRouterState.ts
|
|
444
|
+
const useRouterState = () => {
|
|
445
|
+
const [state] = useStore("alepha.react.router.state");
|
|
446
|
+
if (!state) throw new alepha.AlephaError("Missing react router state");
|
|
447
|
+
return state;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/core/components/ErrorBoundary.tsx
|
|
452
|
+
/**
|
|
453
|
+
* A reusable error boundary for catching rendering errors
|
|
454
|
+
* in any part of the React component tree.
|
|
455
|
+
*/
|
|
456
|
+
var ErrorBoundary = class extends react.default.Component {
|
|
457
|
+
constructor(props) {
|
|
458
|
+
super(props);
|
|
459
|
+
this.state = {};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Update state so the next render shows the fallback UI.
|
|
463
|
+
*/
|
|
464
|
+
static getDerivedStateFromError(error) {
|
|
465
|
+
return { error };
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Lifecycle method called when an error is caught.
|
|
469
|
+
* You can log the error or perform side effects here.
|
|
470
|
+
*/
|
|
471
|
+
componentDidCatch(error, info) {
|
|
472
|
+
if (this.props.onError) this.props.onError(error, info);
|
|
473
|
+
}
|
|
474
|
+
render() {
|
|
475
|
+
if (this.state.error) return this.props.fallback(this.state.error);
|
|
476
|
+
return this.props.children;
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
var ErrorBoundary_default = ErrorBoundary;
|
|
480
|
+
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/core/components/NestedView.tsx
|
|
483
|
+
/**
|
|
484
|
+
* A component that renders the current view of the nested router layer.
|
|
485
|
+
*
|
|
486
|
+
* To be simple, it renders the `element` of the current child page of a parent page.
|
|
487
|
+
*
|
|
488
|
+
* @example
|
|
489
|
+
* ```tsx
|
|
490
|
+
* import { NestedView } from "@alepha/react";
|
|
491
|
+
*
|
|
492
|
+
* class App {
|
|
493
|
+
* parent = $page({
|
|
494
|
+
* component: () => <NestedView />,
|
|
495
|
+
* });
|
|
496
|
+
*
|
|
497
|
+
* child = $page({
|
|
498
|
+
* parent: this.root,
|
|
499
|
+
* component: () => <div>Child Page</div>,
|
|
500
|
+
* });
|
|
501
|
+
* }
|
|
502
|
+
* ```
|
|
503
|
+
*/
|
|
504
|
+
const NestedView = (props) => {
|
|
505
|
+
const index = (0, react.use)(RouterLayerContext)?.index ?? 0;
|
|
506
|
+
const state = useRouterState();
|
|
507
|
+
const [view, setView] = (0, react.useState)(state.layers[index]?.element);
|
|
508
|
+
const [animation, setAnimation] = (0, react.useState)("");
|
|
509
|
+
const animationExitDuration = (0, react.useRef)(0);
|
|
510
|
+
const animationExitNow = (0, react.useRef)(0);
|
|
511
|
+
useEvents({
|
|
512
|
+
"react:transition:begin": async ({ previous, state: state$1 }) => {
|
|
513
|
+
const layer = previous.layers[index];
|
|
514
|
+
if (`${state$1.url.pathname}/`.startsWith(`${layer?.path}/`)) return;
|
|
515
|
+
const animationExit = parseAnimation(layer.route?.animation, state$1, "exit");
|
|
516
|
+
if (animationExit) {
|
|
517
|
+
const duration = animationExit.duration || 200;
|
|
518
|
+
animationExitNow.current = Date.now();
|
|
519
|
+
animationExitDuration.current = duration;
|
|
520
|
+
setAnimation(animationExit.animation);
|
|
521
|
+
} else {
|
|
522
|
+
animationExitNow.current = 0;
|
|
523
|
+
animationExitDuration.current = 0;
|
|
524
|
+
setAnimation("");
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
"react:transition:end": async ({ state: state$1 }) => {
|
|
528
|
+
const layer = state$1.layers[index];
|
|
529
|
+
if (animationExitNow.current) {
|
|
530
|
+
const duration = animationExitDuration.current;
|
|
531
|
+
const diff = Date.now() - animationExitNow.current;
|
|
532
|
+
if (diff < duration) await new Promise((resolve) => setTimeout(resolve, duration - diff));
|
|
533
|
+
}
|
|
534
|
+
if (!layer?.cache) {
|
|
535
|
+
setView(layer?.element);
|
|
536
|
+
const animationEnter = parseAnimation(layer?.route?.animation, state$1, "enter");
|
|
537
|
+
if (animationEnter) setAnimation(animationEnter.animation);
|
|
538
|
+
else setAnimation("");
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}, []);
|
|
542
|
+
let element = view ?? props.children ?? null;
|
|
543
|
+
if (animation) element = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
544
|
+
style: {
|
|
545
|
+
display: "flex",
|
|
546
|
+
flex: 1,
|
|
547
|
+
height: "100%",
|
|
548
|
+
width: "100%",
|
|
549
|
+
position: "relative",
|
|
550
|
+
overflow: "hidden"
|
|
551
|
+
},
|
|
552
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
553
|
+
style: {
|
|
554
|
+
height: "100%",
|
|
555
|
+
width: "100%",
|
|
556
|
+
display: "flex",
|
|
557
|
+
animation
|
|
558
|
+
},
|
|
559
|
+
children: element
|
|
560
|
+
})
|
|
561
|
+
});
|
|
562
|
+
if (props.errorBoundary === false) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: element });
|
|
563
|
+
if (props.errorBoundary) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBoundary_default, {
|
|
564
|
+
fallback: props.errorBoundary,
|
|
565
|
+
children: element
|
|
566
|
+
});
|
|
567
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBoundary_default, {
|
|
568
|
+
fallback: (error) => {
|
|
569
|
+
const result = state.onError(error, state);
|
|
570
|
+
if (result instanceof Redirection) return "Redirection inside ErrorBoundary is not allowed.";
|
|
571
|
+
return result;
|
|
572
|
+
},
|
|
573
|
+
children: element
|
|
574
|
+
});
|
|
575
|
+
};
|
|
576
|
+
var NestedView_default = (0, react.memo)(NestedView);
|
|
577
|
+
function parseAnimation(animationLike, state, type = "enter") {
|
|
578
|
+
if (!animationLike) return;
|
|
579
|
+
const DEFAULT_DURATION = 300;
|
|
580
|
+
const animation = typeof animationLike === "function" ? animationLike(state) : animationLike;
|
|
581
|
+
if (typeof animation === "string") {
|
|
582
|
+
if (type === "exit") return;
|
|
583
|
+
return {
|
|
584
|
+
duration: DEFAULT_DURATION,
|
|
585
|
+
animation: `${DEFAULT_DURATION}ms ${animation}`
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
if (typeof animation === "object") {
|
|
589
|
+
const anim = animation[type];
|
|
590
|
+
const duration = typeof anim === "object" ? anim.duration ?? DEFAULT_DURATION : DEFAULT_DURATION;
|
|
591
|
+
const name = typeof anim === "object" ? anim.name : anim;
|
|
592
|
+
if (type === "exit") return {
|
|
593
|
+
duration,
|
|
594
|
+
animation: `${duration}ms ${typeof anim === "object" ? anim.timing ?? "" : ""} ${name}`
|
|
595
|
+
};
|
|
596
|
+
return {
|
|
597
|
+
duration,
|
|
598
|
+
animation: `${duration}ms ${typeof anim === "object" ? anim.timing ?? "" : ""} ${name}`
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
//#endregion
|
|
604
|
+
//#region src/core/components/NotFound.tsx
|
|
605
|
+
function NotFoundPage(props) {
|
|
606
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
607
|
+
style: {
|
|
608
|
+
height: "100vh",
|
|
609
|
+
display: "flex",
|
|
610
|
+
flexDirection: "column",
|
|
611
|
+
justifyContent: "center",
|
|
612
|
+
alignItems: "center",
|
|
613
|
+
textAlign: "center",
|
|
614
|
+
fontFamily: "sans-serif",
|
|
615
|
+
padding: "1rem",
|
|
616
|
+
...props.style
|
|
617
|
+
},
|
|
618
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h1", {
|
|
619
|
+
style: {
|
|
620
|
+
fontSize: "1rem",
|
|
621
|
+
marginBottom: "0.5rem"
|
|
622
|
+
},
|
|
623
|
+
children: "404 - This page does not exist"
|
|
624
|
+
})
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region src/core/providers/ReactPageProvider.ts
|
|
630
|
+
const envSchema$2 = alepha.t.object({ REACT_STRICT_MODE: alepha.t.boolean({ default: true }) });
|
|
631
|
+
var ReactPageProvider = class {
|
|
632
|
+
log = (0, alepha_logger.$logger)();
|
|
633
|
+
env = (0, alepha.$env)(envSchema$2);
|
|
634
|
+
alepha = (0, alepha.$inject)(alepha.Alepha);
|
|
635
|
+
pages = [];
|
|
636
|
+
getPages() {
|
|
637
|
+
return this.pages;
|
|
638
|
+
}
|
|
639
|
+
getConcretePages() {
|
|
640
|
+
const pages = [];
|
|
641
|
+
for (const page of this.pages) {
|
|
642
|
+
if (page.children && page.children.length > 0) continue;
|
|
643
|
+
const fullPath = this.pathname(page.name);
|
|
644
|
+
if (fullPath.includes(":") || fullPath.includes("*")) {
|
|
645
|
+
if (typeof page.static === "object") {
|
|
646
|
+
const entries = page.static.entries;
|
|
647
|
+
if (entries && entries.length > 0) for (const entry of entries) {
|
|
648
|
+
const params = entry.params;
|
|
649
|
+
const path = this.compile(page.path ?? "", params);
|
|
650
|
+
if (!path.includes(":") && !path.includes("*")) pages.push({
|
|
651
|
+
...page,
|
|
652
|
+
name: params[Object.keys(params)[0]],
|
|
653
|
+
path,
|
|
654
|
+
...entry
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
pages.push(page);
|
|
661
|
+
}
|
|
662
|
+
return pages;
|
|
663
|
+
}
|
|
664
|
+
page(name) {
|
|
665
|
+
for (const page of this.pages) if (page.name === name) return page;
|
|
666
|
+
throw new alepha.AlephaError(`Page '${name}' not found`);
|
|
667
|
+
}
|
|
668
|
+
pathname(name, options = {}) {
|
|
669
|
+
const page = this.page(name);
|
|
670
|
+
if (!page) throw new Error(`Page ${name} not found`);
|
|
671
|
+
let url = page.path ?? "";
|
|
672
|
+
let parent = page.parent;
|
|
673
|
+
while (parent) {
|
|
674
|
+
url = `${parent.path ?? ""}/${url}`;
|
|
675
|
+
parent = parent.parent;
|
|
676
|
+
}
|
|
677
|
+
url = this.compile(url, options.params ?? {});
|
|
678
|
+
if (options.query) {
|
|
679
|
+
const query = new URLSearchParams(options.query);
|
|
680
|
+
if (query.toString()) url += `?${query.toString()}`;
|
|
681
|
+
}
|
|
682
|
+
return url.replace(/\/\/+/g, "/") || "/";
|
|
683
|
+
}
|
|
684
|
+
url(name, options = {}) {
|
|
685
|
+
return new URL(this.pathname(name, options), options.host ?? `http://localhost`);
|
|
686
|
+
}
|
|
687
|
+
root(state) {
|
|
688
|
+
const root = (0, react.createElement)(AlephaContext.Provider, { value: this.alepha }, (0, react.createElement)(NestedView_default, {}, state.layers[0]?.element));
|
|
689
|
+
if (this.env.REACT_STRICT_MODE) return (0, react.createElement)(react.StrictMode, {}, root);
|
|
690
|
+
return root;
|
|
691
|
+
}
|
|
692
|
+
convertStringObjectToObject = (schema, value) => {
|
|
693
|
+
if (alepha.t.schema.isObject(schema) && typeof value === "object") {
|
|
694
|
+
for (const key in schema.properties) if (alepha.t.schema.isObject(schema.properties[key]) && typeof value[key] === "string") try {
|
|
695
|
+
value[key] = this.alepha.codec.decode(schema.properties[key], decodeURIComponent(value[key]));
|
|
696
|
+
} catch (e) {}
|
|
697
|
+
}
|
|
698
|
+
return value;
|
|
699
|
+
};
|
|
700
|
+
/**
|
|
701
|
+
* Create a new RouterState based on a given route and request.
|
|
702
|
+
* This method resolves the layers for the route, applying any query and params schemas defined in the route.
|
|
703
|
+
* It also handles errors and redirects.
|
|
704
|
+
*/
|
|
705
|
+
async createLayers(route, state, previous = []) {
|
|
706
|
+
let context = {};
|
|
707
|
+
const stack = [{ route }];
|
|
708
|
+
let parent = route.parent;
|
|
709
|
+
while (parent) {
|
|
710
|
+
stack.unshift({ route: parent });
|
|
711
|
+
parent = parent.parent;
|
|
712
|
+
}
|
|
713
|
+
let forceRefresh = false;
|
|
714
|
+
for (let i = 0; i < stack.length; i++) {
|
|
715
|
+
const it = stack[i];
|
|
716
|
+
const route$1 = it.route;
|
|
717
|
+
const config = {};
|
|
718
|
+
try {
|
|
719
|
+
this.convertStringObjectToObject(route$1.schema?.query, state.query);
|
|
720
|
+
config.query = route$1.schema?.query ? this.alepha.codec.decode(route$1.schema.query, state.query) : {};
|
|
721
|
+
} catch (e) {
|
|
722
|
+
it.error = e;
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
config.params = route$1.schema?.params ? this.alepha.codec.decode(route$1.schema.params, state.params) : {};
|
|
727
|
+
} catch (e) {
|
|
728
|
+
it.error = e;
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
it.config = { ...config };
|
|
732
|
+
if (previous?.[i] && !forceRefresh && previous[i].name === route$1.name) {
|
|
733
|
+
const url = (str) => str ? str.replace(/\/\/+/g, "/") : "/";
|
|
734
|
+
if (JSON.stringify({
|
|
735
|
+
part: url(previous[i].part),
|
|
736
|
+
params: previous[i].config?.params ?? {}
|
|
737
|
+
}) === JSON.stringify({
|
|
738
|
+
part: url(route$1.path),
|
|
739
|
+
params: config.params ?? {}
|
|
740
|
+
})) {
|
|
741
|
+
it.props = previous[i].props;
|
|
742
|
+
it.error = previous[i].error;
|
|
743
|
+
it.cache = true;
|
|
744
|
+
context = {
|
|
745
|
+
...context,
|
|
746
|
+
...it.props
|
|
747
|
+
};
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
forceRefresh = true;
|
|
751
|
+
}
|
|
752
|
+
if (!route$1.resolve) continue;
|
|
753
|
+
try {
|
|
754
|
+
const args = Object.create(state);
|
|
755
|
+
Object.assign(args, config, context);
|
|
756
|
+
const props = await route$1.resolve?.(args) ?? {};
|
|
757
|
+
it.props = { ...props };
|
|
758
|
+
context = {
|
|
759
|
+
...context,
|
|
760
|
+
...props
|
|
761
|
+
};
|
|
762
|
+
} catch (e) {
|
|
763
|
+
if (e instanceof Redirection) return { redirect: e.redirect };
|
|
764
|
+
this.log.error("Page resolver has failed", e);
|
|
765
|
+
it.error = e;
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
let acc = "";
|
|
770
|
+
for (let i = 0; i < stack.length; i++) {
|
|
771
|
+
const it = stack[i];
|
|
772
|
+
const props = it.props ?? {};
|
|
773
|
+
const params = { ...it.config?.params };
|
|
774
|
+
for (const key of Object.keys(params)) params[key] = String(params[key]);
|
|
775
|
+
acc += "/";
|
|
776
|
+
acc += it.route.path ? this.compile(it.route.path, params) : "";
|
|
777
|
+
const path = acc.replace(/\/+/, "/");
|
|
778
|
+
const localErrorHandler = this.getErrorHandler(it.route);
|
|
779
|
+
if (localErrorHandler) {
|
|
780
|
+
const onErrorParent = state.onError;
|
|
781
|
+
state.onError = (error, context$1) => {
|
|
782
|
+
const result = localErrorHandler(error, context$1);
|
|
783
|
+
if (result === void 0) return onErrorParent(error, context$1);
|
|
784
|
+
return result;
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
if (!it.error) try {
|
|
788
|
+
const element = await this.createElement(it.route, {
|
|
789
|
+
...props,
|
|
790
|
+
...context
|
|
791
|
+
});
|
|
792
|
+
state.layers.push({
|
|
793
|
+
name: it.route.name,
|
|
794
|
+
props,
|
|
795
|
+
part: it.route.path,
|
|
796
|
+
config: it.config,
|
|
797
|
+
element: this.renderView(i + 1, path, element, it.route),
|
|
798
|
+
index: i + 1,
|
|
799
|
+
path,
|
|
800
|
+
route: it.route,
|
|
801
|
+
cache: it.cache
|
|
802
|
+
});
|
|
803
|
+
} catch (e) {
|
|
804
|
+
it.error = e;
|
|
805
|
+
}
|
|
806
|
+
if (it.error) try {
|
|
807
|
+
let element = await state.onError(it.error, state);
|
|
808
|
+
if (element === void 0) throw it.error;
|
|
809
|
+
if (element instanceof Redirection) return { redirect: element.redirect };
|
|
810
|
+
if (element === null) element = this.renderError(it.error);
|
|
811
|
+
state.layers.push({
|
|
812
|
+
props,
|
|
813
|
+
error: it.error,
|
|
814
|
+
name: it.route.name,
|
|
815
|
+
part: it.route.path,
|
|
816
|
+
config: it.config,
|
|
817
|
+
element: this.renderView(i + 1, path, element, it.route),
|
|
818
|
+
index: i + 1,
|
|
819
|
+
path,
|
|
820
|
+
route: it.route
|
|
821
|
+
});
|
|
822
|
+
break;
|
|
823
|
+
} catch (e) {
|
|
824
|
+
if (e instanceof Redirection) return { redirect: e.redirect };
|
|
825
|
+
throw e;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return { state };
|
|
829
|
+
}
|
|
830
|
+
createRedirectionLayer(redirect) {
|
|
831
|
+
return { redirect };
|
|
832
|
+
}
|
|
833
|
+
getErrorHandler(route) {
|
|
834
|
+
if (route.errorHandler) return route.errorHandler;
|
|
835
|
+
let parent = route.parent;
|
|
836
|
+
while (parent) {
|
|
837
|
+
if (parent.errorHandler) return parent.errorHandler;
|
|
838
|
+
parent = parent.parent;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
async createElement(page, props) {
|
|
842
|
+
if (page.lazy && page.component) this.log.warn(`Page ${page.name} has both lazy and component options, lazy will be used`);
|
|
843
|
+
if (page.lazy) return (0, react.createElement)((await page.lazy()).default, props);
|
|
844
|
+
if (page.component) return (0, react.createElement)(page.component, props);
|
|
845
|
+
}
|
|
846
|
+
renderError(error) {
|
|
847
|
+
return (0, react.createElement)(ErrorViewer_default, {
|
|
848
|
+
error,
|
|
849
|
+
alepha: this.alepha
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
renderEmptyView() {
|
|
853
|
+
return (0, react.createElement)(NestedView_default, {});
|
|
854
|
+
}
|
|
855
|
+
href(page, params = {}) {
|
|
856
|
+
const found = this.pages.find((it) => it.name === page.options.name);
|
|
857
|
+
if (!found) throw new Error(`Page ${page.options.name} not found`);
|
|
858
|
+
let url = found.path ?? "";
|
|
859
|
+
let parent = found.parent;
|
|
860
|
+
while (parent) {
|
|
861
|
+
url = `${parent.path ?? ""}/${url}`;
|
|
862
|
+
parent = parent.parent;
|
|
863
|
+
}
|
|
864
|
+
url = this.compile(url, params);
|
|
865
|
+
return url.replace(/\/\/+/g, "/") || "/";
|
|
866
|
+
}
|
|
867
|
+
compile(path, params = {}) {
|
|
868
|
+
for (const [key, value] of Object.entries(params)) path = path.replace(`:${key}`, value);
|
|
869
|
+
return path;
|
|
870
|
+
}
|
|
871
|
+
renderView(index, path, view, page) {
|
|
872
|
+
view ??= this.renderEmptyView();
|
|
873
|
+
const element = page.client ? (0, react.createElement)(ClientOnly_default, typeof page.client === "object" ? page.client : {}, view) : view;
|
|
874
|
+
return (0, react.createElement)(RouterLayerContext.Provider, { value: {
|
|
875
|
+
index,
|
|
876
|
+
path
|
|
877
|
+
} }, element);
|
|
878
|
+
}
|
|
879
|
+
configure = (0, alepha.$hook)({
|
|
880
|
+
on: "configure",
|
|
881
|
+
handler: () => {
|
|
882
|
+
let hasNotFoundHandler = false;
|
|
883
|
+
const pages = this.alepha.descriptors($page);
|
|
884
|
+
const hasParent = (it) => {
|
|
885
|
+
if (it.options.parent) return true;
|
|
886
|
+
for (const page of pages) if ((page.options.children ? Array.isArray(page.options.children) ? page.options.children : page.options.children() : []).includes(it)) return true;
|
|
887
|
+
};
|
|
888
|
+
for (const page of pages) {
|
|
889
|
+
if (page.options.path === "/*") hasNotFoundHandler = true;
|
|
890
|
+
if (hasParent(page)) continue;
|
|
891
|
+
this.add(this.map(pages, page));
|
|
892
|
+
}
|
|
893
|
+
if (!hasNotFoundHandler && pages.length > 0) this.add({
|
|
894
|
+
path: "/*",
|
|
895
|
+
name: "notFound",
|
|
896
|
+
cache: true,
|
|
897
|
+
component: NotFoundPage,
|
|
898
|
+
onServerResponse: ({ reply }) => {
|
|
899
|
+
reply.status = 404;
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
map(pages, target) {
|
|
905
|
+
const children = target.options.children ? Array.isArray(target.options.children) ? target.options.children : target.options.children() : [];
|
|
906
|
+
const getChildrenFromParent = (it) => {
|
|
907
|
+
const children$1 = [];
|
|
908
|
+
for (const page of pages) if (page.options.parent === it) children$1.push(page);
|
|
909
|
+
return children$1;
|
|
910
|
+
};
|
|
911
|
+
children.push(...getChildrenFromParent(target));
|
|
912
|
+
return {
|
|
913
|
+
...target.options,
|
|
914
|
+
name: target.name,
|
|
915
|
+
parent: void 0,
|
|
916
|
+
children: children.map((it) => this.map(pages, it))
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
add(entry) {
|
|
920
|
+
if (this.alepha.isReady()) throw new alepha.AlephaError("Router is already initialized");
|
|
921
|
+
entry.name ??= this.nextId();
|
|
922
|
+
const page = entry;
|
|
923
|
+
page.match = this.createMatch(page);
|
|
924
|
+
this.pages.push(page);
|
|
925
|
+
if (page.children) for (const child of page.children) {
|
|
926
|
+
child.parent = page;
|
|
927
|
+
this.add(child);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
createMatch(page) {
|
|
931
|
+
let url = page.path ?? "/";
|
|
932
|
+
let target = page.parent;
|
|
933
|
+
while (target) {
|
|
934
|
+
url = `${target.path ?? ""}/${url}`;
|
|
935
|
+
target = target.parent;
|
|
936
|
+
}
|
|
937
|
+
let path = url.replace(/\/\/+/g, "/");
|
|
938
|
+
if (path.endsWith("/") && path !== "/") path = path.slice(0, -1);
|
|
939
|
+
return path;
|
|
940
|
+
}
|
|
941
|
+
_next = 0;
|
|
942
|
+
nextId() {
|
|
943
|
+
this._next += 1;
|
|
944
|
+
return `P${this._next}`;
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
const isPageRoute = (it) => {
|
|
948
|
+
return it && typeof it === "object" && typeof it.path === "string" && typeof it.page === "object";
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
//#endregion
|
|
952
|
+
//#region src/core/providers/ReactServerProvider.ts
|
|
953
|
+
const envSchema$1 = alepha.t.object({
|
|
954
|
+
REACT_SSR_ENABLED: alepha.t.optional(alepha.t.boolean()),
|
|
955
|
+
REACT_ROOT_ID: alepha.t.text({ default: "root" }),
|
|
956
|
+
REACT_SERVER_TEMPLATE: alepha.t.optional(alepha.t.text({ size: "rich" }))
|
|
957
|
+
});
|
|
958
|
+
/**
|
|
959
|
+
* React server provider configuration atom
|
|
960
|
+
*/
|
|
961
|
+
const reactServerOptions = (0, alepha.$atom)({
|
|
962
|
+
name: "alepha.react.server.options",
|
|
963
|
+
schema: alepha.t.object({
|
|
964
|
+
publicDir: alepha.t.string(),
|
|
965
|
+
staticServer: alepha.t.object({
|
|
966
|
+
disabled: alepha.t.boolean(),
|
|
967
|
+
path: alepha.t.string({ description: "URL path where static files will be served." })
|
|
968
|
+
})
|
|
969
|
+
}),
|
|
970
|
+
default: {
|
|
971
|
+
publicDir: "public",
|
|
972
|
+
staticServer: {
|
|
973
|
+
disabled: false,
|
|
974
|
+
path: "/"
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
var ReactServerProvider = class {
|
|
979
|
+
log = (0, alepha_logger.$logger)();
|
|
980
|
+
alepha = (0, alepha.$inject)(alepha.Alepha);
|
|
981
|
+
env = (0, alepha.$env)(envSchema$1);
|
|
982
|
+
pageApi = (0, alepha.$inject)(ReactPageProvider);
|
|
983
|
+
serverProvider = (0, alepha.$inject)(alepha_server.ServerProvider);
|
|
984
|
+
serverStaticProvider = (0, alepha.$inject)(alepha_server_static.ServerStaticProvider);
|
|
985
|
+
serverRouterProvider = (0, alepha.$inject)(alepha_server.ServerRouterProvider);
|
|
986
|
+
serverTimingProvider = (0, alepha.$inject)(alepha_server.ServerTimingProvider);
|
|
987
|
+
ROOT_DIV_REGEX = new RegExp(`<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`, "is");
|
|
988
|
+
preprocessedTemplate = null;
|
|
989
|
+
options = (0, alepha.$use)(reactServerOptions);
|
|
990
|
+
/**
|
|
991
|
+
* Configure the React server provider.
|
|
992
|
+
*/
|
|
993
|
+
onConfigure = (0, alepha.$hook)({
|
|
994
|
+
on: "configure",
|
|
995
|
+
handler: async () => {
|
|
996
|
+
const ssrEnabled = this.alepha.descriptors($page).length > 0 && this.env.REACT_SSR_ENABLED !== false;
|
|
997
|
+
this.alepha.state.set("alepha.react.server.ssr", ssrEnabled);
|
|
998
|
+
if (this.alepha.isViteDev()) {
|
|
999
|
+
await this.configureVite(ssrEnabled);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
let root = "";
|
|
1003
|
+
if (!this.alepha.isServerless()) {
|
|
1004
|
+
root = this.getPublicDirectory();
|
|
1005
|
+
if (!root) this.log.warn("Missing static files, static file server will be disabled");
|
|
1006
|
+
else {
|
|
1007
|
+
this.log.debug(`Using static files from: ${root}`);
|
|
1008
|
+
await this.configureStaticServer(root);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (ssrEnabled) {
|
|
1012
|
+
await this.registerPages(async () => this.template);
|
|
1013
|
+
this.log.info("SSR OK");
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
this.log.info("SSR is disabled, use History API fallback");
|
|
1017
|
+
this.serverRouterProvider.createRoute({
|
|
1018
|
+
path: "*",
|
|
1019
|
+
handler: async ({ url, reply }) => {
|
|
1020
|
+
if (url.pathname.includes(".")) {
|
|
1021
|
+
reply.headers["content-type"] = "text/plain";
|
|
1022
|
+
reply.body = "Not Found";
|
|
1023
|
+
reply.status = 404;
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
reply.headers["content-type"] = "text/html";
|
|
1027
|
+
return this.template;
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
get template() {
|
|
1033
|
+
return this.alepha.env.REACT_SERVER_TEMPLATE ?? "<!DOCTYPE html><html lang='en'><head></head><body></body></html>";
|
|
1034
|
+
}
|
|
1035
|
+
async registerPages(templateLoader) {
|
|
1036
|
+
const template = await templateLoader();
|
|
1037
|
+
if (template) this.preprocessedTemplate = this.preprocessTemplate(template);
|
|
1038
|
+
for (const page of this.pageApi.getPages()) {
|
|
1039
|
+
if (page.children?.length) continue;
|
|
1040
|
+
this.log.debug(`+ ${page.match} -> ${page.name}`);
|
|
1041
|
+
this.serverRouterProvider.createRoute({
|
|
1042
|
+
...page,
|
|
1043
|
+
schema: void 0,
|
|
1044
|
+
method: "GET",
|
|
1045
|
+
path: page.match,
|
|
1046
|
+
handler: this.createHandler(page, templateLoader)
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Get the public directory path where static files are located.
|
|
1052
|
+
*/
|
|
1053
|
+
getPublicDirectory() {
|
|
1054
|
+
const maybe = [(0, node_path.join)(process.cwd(), `dist/${this.options.publicDir}`), (0, node_path.join)(process.cwd(), this.options.publicDir)];
|
|
1055
|
+
for (const it of maybe) if ((0, node_fs.existsSync)(it)) return it;
|
|
1056
|
+
return "";
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Configure the static file server to serve files from the given root directory.
|
|
1060
|
+
*/
|
|
1061
|
+
async configureStaticServer(root) {
|
|
1062
|
+
await this.serverStaticProvider.createStaticServer({
|
|
1063
|
+
root,
|
|
1064
|
+
cacheControl: {
|
|
1065
|
+
maxAge: 3600,
|
|
1066
|
+
immutable: true
|
|
1067
|
+
},
|
|
1068
|
+
...this.options.staticServer
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Configure Vite for SSR.
|
|
1073
|
+
*/
|
|
1074
|
+
async configureVite(ssrEnabled) {
|
|
1075
|
+
if (!ssrEnabled) return;
|
|
1076
|
+
this.log.info("SSR (dev) OK");
|
|
1077
|
+
const url = `http://${process.env.SERVER_HOST}:${process.env.SERVER_PORT}`;
|
|
1078
|
+
await this.registerPages(() => fetch(`${url}/index.html`).then((it) => it.text()).catch(() => void 0));
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* For testing purposes, creates a render function that can be used.
|
|
1082
|
+
*/
|
|
1083
|
+
async render(name, options = {}) {
|
|
1084
|
+
const page = this.pageApi.page(name);
|
|
1085
|
+
const url = new URL(this.pageApi.url(name, options));
|
|
1086
|
+
const state = {
|
|
1087
|
+
url,
|
|
1088
|
+
params: options.params ?? {},
|
|
1089
|
+
query: options.query ?? {},
|
|
1090
|
+
onError: () => null,
|
|
1091
|
+
layers: [],
|
|
1092
|
+
meta: {}
|
|
1093
|
+
};
|
|
1094
|
+
this.log.trace("Rendering", { url });
|
|
1095
|
+
await this.alepha.events.emit("react:server:render:begin", { state });
|
|
1096
|
+
const { redirect } = await this.pageApi.createLayers(page, state);
|
|
1097
|
+
if (redirect) return {
|
|
1098
|
+
state,
|
|
1099
|
+
html: "",
|
|
1100
|
+
redirect
|
|
1101
|
+
};
|
|
1102
|
+
if (!options.html) {
|
|
1103
|
+
this.alepha.state.set("alepha.react.router.state", state);
|
|
1104
|
+
return {
|
|
1105
|
+
state,
|
|
1106
|
+
html: (0, react_dom_server.renderToString)(this.pageApi.root(state))
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
const template = this.template ?? "";
|
|
1110
|
+
const html = this.renderToHtml(template, state, options.hydration);
|
|
1111
|
+
if (html instanceof Redirection) return {
|
|
1112
|
+
state,
|
|
1113
|
+
html: "",
|
|
1114
|
+
redirect
|
|
1115
|
+
};
|
|
1116
|
+
const result = {
|
|
1117
|
+
state,
|
|
1118
|
+
html
|
|
1119
|
+
};
|
|
1120
|
+
await this.alepha.events.emit("react:server:render:end", result);
|
|
1121
|
+
return result;
|
|
1122
|
+
}
|
|
1123
|
+
createHandler(route, templateLoader) {
|
|
1124
|
+
return async (serverRequest) => {
|
|
1125
|
+
const { url, reply, query, params } = serverRequest;
|
|
1126
|
+
const template = await templateLoader();
|
|
1127
|
+
if (!template) throw new alepha.AlephaError("Missing template for SSR rendering");
|
|
1128
|
+
this.log.trace("Rendering page", { name: route.name });
|
|
1129
|
+
const state = {
|
|
1130
|
+
url,
|
|
1131
|
+
params,
|
|
1132
|
+
query,
|
|
1133
|
+
onError: () => null,
|
|
1134
|
+
layers: []
|
|
1135
|
+
};
|
|
1136
|
+
if (this.alepha.has(alepha_server_links.ServerLinksProvider)) this.alepha.state.set("alepha.server.request.apiLinks", await this.alepha.inject(alepha_server_links.ServerLinksProvider).getUserApiLinks({
|
|
1137
|
+
user: serverRequest.user,
|
|
1138
|
+
authorization: serverRequest.headers.authorization
|
|
1139
|
+
}));
|
|
1140
|
+
let target = route;
|
|
1141
|
+
while (target) {
|
|
1142
|
+
if (route.can && !route.can()) {
|
|
1143
|
+
reply.status = 403;
|
|
1144
|
+
reply.headers["content-type"] = "text/plain";
|
|
1145
|
+
return "Forbidden";
|
|
1146
|
+
}
|
|
1147
|
+
target = target.parent;
|
|
1148
|
+
}
|
|
1149
|
+
await this.alepha.events.emit("react:server:render:begin", {
|
|
1150
|
+
request: serverRequest,
|
|
1151
|
+
state
|
|
1152
|
+
});
|
|
1153
|
+
this.serverTimingProvider.beginTiming("createLayers");
|
|
1154
|
+
const { redirect } = await this.pageApi.createLayers(route, state);
|
|
1155
|
+
this.serverTimingProvider.endTiming("createLayers");
|
|
1156
|
+
if (redirect) return reply.redirect(redirect);
|
|
1157
|
+
reply.headers["content-type"] = "text/html";
|
|
1158
|
+
reply.headers["cache-control"] = "no-store, no-cache, must-revalidate, proxy-revalidate";
|
|
1159
|
+
reply.headers.pragma = "no-cache";
|
|
1160
|
+
reply.headers.expires = "0";
|
|
1161
|
+
const html = this.renderToHtml(template, state);
|
|
1162
|
+
if (html instanceof Redirection) {
|
|
1163
|
+
reply.redirect(typeof html.redirect === "string" ? html.redirect : this.pageApi.href(html.redirect));
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
const event = {
|
|
1167
|
+
request: serverRequest,
|
|
1168
|
+
state,
|
|
1169
|
+
html
|
|
1170
|
+
};
|
|
1171
|
+
await this.alepha.events.emit("react:server:render:end", event);
|
|
1172
|
+
route.onServerResponse?.(serverRequest);
|
|
1173
|
+
this.log.trace("Page rendered", { name: route.name });
|
|
1174
|
+
return event.html;
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
renderToHtml(template, state, hydration = true) {
|
|
1178
|
+
const element = this.pageApi.root(state);
|
|
1179
|
+
this.alepha.state.set("alepha.react.router.state", state);
|
|
1180
|
+
this.serverTimingProvider.beginTiming("renderToString");
|
|
1181
|
+
let app = "";
|
|
1182
|
+
try {
|
|
1183
|
+
app = (0, react_dom_server.renderToString)(element);
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
this.log.error("renderToString has failed, fallback to error handler", error);
|
|
1186
|
+
const element$1 = state.onError(error, state);
|
|
1187
|
+
if (element$1 instanceof Redirection) return element$1;
|
|
1188
|
+
app = (0, react_dom_server.renderToString)(element$1);
|
|
1189
|
+
this.log.debug("Error handled successfully with fallback");
|
|
1190
|
+
}
|
|
1191
|
+
this.serverTimingProvider.endTiming("renderToString");
|
|
1192
|
+
const response = { html: template };
|
|
1193
|
+
if (hydration) {
|
|
1194
|
+
const { request, context, ...store } = this.alepha.context.als?.getStore() ?? {};
|
|
1195
|
+
const hydrationData = {
|
|
1196
|
+
...store,
|
|
1197
|
+
"alepha.react.router.state": void 0,
|
|
1198
|
+
layers: state.layers.map((it) => ({
|
|
1199
|
+
...it,
|
|
1200
|
+
error: it.error ? {
|
|
1201
|
+
...it.error,
|
|
1202
|
+
name: it.error.name,
|
|
1203
|
+
message: it.error.message,
|
|
1204
|
+
stack: !this.alepha.isProduction() ? it.error.stack : void 0
|
|
1205
|
+
} : void 0,
|
|
1206
|
+
index: void 0,
|
|
1207
|
+
path: void 0,
|
|
1208
|
+
element: void 0,
|
|
1209
|
+
route: void 0
|
|
1210
|
+
}))
|
|
1211
|
+
};
|
|
1212
|
+
const script = `<script>window.__ssr=${JSON.stringify(hydrationData)}<\/script>`;
|
|
1213
|
+
this.fillTemplate(response, app, script);
|
|
1214
|
+
}
|
|
1215
|
+
return response.html;
|
|
1216
|
+
}
|
|
1217
|
+
preprocessTemplate(template) {
|
|
1218
|
+
const bodyCloseIndex = template.match(/<\/body>/i)?.index ?? template.length;
|
|
1219
|
+
const beforeScript = template.substring(0, bodyCloseIndex);
|
|
1220
|
+
const afterScript = template.substring(bodyCloseIndex);
|
|
1221
|
+
const rootDivMatch = beforeScript.match(this.ROOT_DIV_REGEX);
|
|
1222
|
+
if (rootDivMatch) {
|
|
1223
|
+
const beforeDiv = beforeScript.substring(0, rootDivMatch.index);
|
|
1224
|
+
const afterDivStart = rootDivMatch.index + rootDivMatch[0].length;
|
|
1225
|
+
const afterDiv = beforeScript.substring(afterDivStart);
|
|
1226
|
+
return {
|
|
1227
|
+
beforeApp: `${beforeDiv}<div${rootDivMatch[1]} id="${this.env.REACT_ROOT_ID}"${rootDivMatch[2]}>`,
|
|
1228
|
+
afterApp: `</div>${afterDiv}`,
|
|
1229
|
+
beforeScript: "",
|
|
1230
|
+
afterScript
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
const bodyMatch = beforeScript.match(/<body([^>]*)>/i);
|
|
1234
|
+
if (bodyMatch) {
|
|
1235
|
+
const beforeBody = beforeScript.substring(0, bodyMatch.index + bodyMatch[0].length);
|
|
1236
|
+
const afterBody = beforeScript.substring(bodyMatch.index + bodyMatch[0].length);
|
|
1237
|
+
return {
|
|
1238
|
+
beforeApp: `${beforeBody}<div id="${this.env.REACT_ROOT_ID}">`,
|
|
1239
|
+
afterApp: `</div>${afterBody}`,
|
|
1240
|
+
beforeScript: "",
|
|
1241
|
+
afterScript
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
return {
|
|
1245
|
+
beforeApp: `<div id="${this.env.REACT_ROOT_ID}">`,
|
|
1246
|
+
afterApp: `</div>`,
|
|
1247
|
+
beforeScript,
|
|
1248
|
+
afterScript
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
fillTemplate(response, app, script) {
|
|
1252
|
+
if (!this.preprocessedTemplate) this.preprocessedTemplate = this.preprocessTemplate(response.html);
|
|
1253
|
+
response.html = this.preprocessedTemplate.beforeApp + app + this.preprocessedTemplate.afterApp + script + this.preprocessedTemplate.afterScript;
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
//#endregion
|
|
1258
|
+
//#region src/core/services/ReactPageServerService.ts
|
|
1259
|
+
var ReactPageServerService = class extends ReactPageService {
|
|
1260
|
+
reactServerProvider = (0, alepha.$inject)(ReactServerProvider);
|
|
1261
|
+
serverProvider = (0, alepha.$inject)(alepha_server.ServerProvider);
|
|
1262
|
+
async render(name, options = {}) {
|
|
1263
|
+
return this.reactServerProvider.render(name, options);
|
|
1264
|
+
}
|
|
1265
|
+
async fetch(pathname, options = {}) {
|
|
1266
|
+
const response = await fetch(`${this.serverProvider.hostname}/${pathname}`);
|
|
1267
|
+
const html = await response.text();
|
|
1268
|
+
if (options?.html) return {
|
|
1269
|
+
html,
|
|
1270
|
+
response
|
|
1271
|
+
};
|
|
1272
|
+
const match = html.match(this.reactServerProvider.ROOT_DIV_REGEX);
|
|
1273
|
+
if (match) return {
|
|
1274
|
+
html: match[3],
|
|
1275
|
+
response
|
|
1276
|
+
};
|
|
1277
|
+
throw new alepha.AlephaError("Invalid HTML response");
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
//#endregion
|
|
1282
|
+
//#region src/core/providers/ReactBrowserRouterProvider.ts
|
|
1283
|
+
var ReactBrowserRouterProvider = class extends alepha_router.RouterProvider {
|
|
1284
|
+
log = (0, alepha_logger.$logger)();
|
|
1285
|
+
alepha = (0, alepha.$inject)(alepha.Alepha);
|
|
1286
|
+
pageApi = (0, alepha.$inject)(ReactPageProvider);
|
|
1287
|
+
add(entry) {
|
|
1288
|
+
this.pageApi.add(entry);
|
|
1289
|
+
}
|
|
1290
|
+
configure = (0, alepha.$hook)({
|
|
1291
|
+
on: "configure",
|
|
1292
|
+
handler: async () => {
|
|
1293
|
+
for (const page of this.pageApi.getPages()) if (page.component || page.lazy) this.push({
|
|
1294
|
+
path: page.match,
|
|
1295
|
+
page
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
async transition(url, previous = [], meta = {}) {
|
|
1300
|
+
const { pathname, search } = url;
|
|
1301
|
+
const state = {
|
|
1302
|
+
url,
|
|
1303
|
+
query: {},
|
|
1304
|
+
params: {},
|
|
1305
|
+
layers: [],
|
|
1306
|
+
onError: () => null,
|
|
1307
|
+
meta
|
|
1308
|
+
};
|
|
1309
|
+
await this.alepha.events.emit("react:action:begin", { type: "transition" });
|
|
1310
|
+
await this.alepha.events.emit("react:transition:begin", {
|
|
1311
|
+
previous: this.alepha.state.get("alepha.react.router.state"),
|
|
1312
|
+
state
|
|
1313
|
+
});
|
|
1314
|
+
try {
|
|
1315
|
+
const { route, params } = this.match(pathname);
|
|
1316
|
+
const query = {};
|
|
1317
|
+
if (search) for (const [key, value] of new URLSearchParams(search).entries()) query[key] = String(value);
|
|
1318
|
+
state.query = query;
|
|
1319
|
+
state.params = params ?? {};
|
|
1320
|
+
if (isPageRoute(route)) {
|
|
1321
|
+
const { redirect } = await this.pageApi.createLayers(route.page, state, previous);
|
|
1322
|
+
if (redirect) return redirect;
|
|
1323
|
+
}
|
|
1324
|
+
if (state.layers.length === 0) state.layers.push({
|
|
1325
|
+
name: "not-found",
|
|
1326
|
+
element: (0, react.createElement)(NotFoundPage),
|
|
1327
|
+
index: 0,
|
|
1328
|
+
path: "/"
|
|
1329
|
+
});
|
|
1330
|
+
await this.alepha.events.emit("react:action:success", { type: "transition" });
|
|
1331
|
+
await this.alepha.events.emit("react:transition:success", { state });
|
|
1332
|
+
} catch (e) {
|
|
1333
|
+
this.log.error("Transition has failed", e);
|
|
1334
|
+
state.layers = [{
|
|
1335
|
+
name: "error",
|
|
1336
|
+
element: this.pageApi.renderError(e),
|
|
1337
|
+
index: 0,
|
|
1338
|
+
path: "/"
|
|
1339
|
+
}];
|
|
1340
|
+
await this.alepha.events.emit("react:action:error", {
|
|
1341
|
+
type: "transition",
|
|
1342
|
+
error: e
|
|
1343
|
+
});
|
|
1344
|
+
await this.alepha.events.emit("react:transition:error", {
|
|
1345
|
+
error: e,
|
|
1346
|
+
state
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
if (previous) for (let i = 0; i < previous.length; i++) {
|
|
1350
|
+
const layer = previous[i];
|
|
1351
|
+
if (state.layers[i]?.name !== layer.name) this.pageApi.page(layer.name)?.onLeave?.();
|
|
1352
|
+
}
|
|
1353
|
+
this.alepha.state.set("alepha.react.router.state", state);
|
|
1354
|
+
await this.alepha.events.emit("react:action:end", { type: "transition" });
|
|
1355
|
+
await this.alepha.events.emit("react:transition:end", { state });
|
|
1356
|
+
}
|
|
1357
|
+
root(state) {
|
|
1358
|
+
return this.pageApi.root(state);
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
//#endregion
|
|
1363
|
+
//#region src/core/providers/ReactBrowserProvider.ts
|
|
1364
|
+
const envSchema = alepha.t.object({ REACT_ROOT_ID: alepha.t.text({ default: "root" }) });
|
|
1365
|
+
/**
|
|
1366
|
+
* React browser renderer configuration atom
|
|
1367
|
+
*/
|
|
1368
|
+
const reactBrowserOptions = (0, alepha.$atom)({
|
|
1369
|
+
name: "alepha.react.browser.options",
|
|
1370
|
+
schema: alepha.t.object({ scrollRestoration: alepha.t.enum(["top", "manual"]) }),
|
|
1371
|
+
default: { scrollRestoration: "top" }
|
|
1372
|
+
});
|
|
1373
|
+
var ReactBrowserProvider = class {
|
|
1374
|
+
env = (0, alepha.$env)(envSchema);
|
|
1375
|
+
log = (0, alepha_logger.$logger)();
|
|
1376
|
+
client = (0, alepha.$inject)(alepha_server_links.LinkProvider);
|
|
1377
|
+
alepha = (0, alepha.$inject)(alepha.Alepha);
|
|
1378
|
+
router = (0, alepha.$inject)(ReactBrowserRouterProvider);
|
|
1379
|
+
dateTimeProvider = (0, alepha.$inject)(alepha_datetime.DateTimeProvider);
|
|
1380
|
+
options = (0, alepha.$use)(reactBrowserOptions);
|
|
1381
|
+
getRootElement() {
|
|
1382
|
+
const root = this.document.getElementById(this.env.REACT_ROOT_ID);
|
|
1383
|
+
if (root) return root;
|
|
1384
|
+
const div = this.document.createElement("div");
|
|
1385
|
+
div.id = this.env.REACT_ROOT_ID;
|
|
1386
|
+
this.document.body.prepend(div);
|
|
1387
|
+
return div;
|
|
1388
|
+
}
|
|
1389
|
+
transitioning;
|
|
1390
|
+
get state() {
|
|
1391
|
+
return this.alepha.state.get("alepha.react.router.state");
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Accessor for Document DOM API.
|
|
1395
|
+
*/
|
|
1396
|
+
get document() {
|
|
1397
|
+
return window.document;
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Accessor for History DOM API.
|
|
1401
|
+
*/
|
|
1402
|
+
get history() {
|
|
1403
|
+
return window.history;
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Accessor for Location DOM API.
|
|
1407
|
+
*/
|
|
1408
|
+
get location() {
|
|
1409
|
+
return window.location;
|
|
1410
|
+
}
|
|
1411
|
+
get base() {
|
|
1412
|
+
const base = {}.env?.BASE_URL;
|
|
1413
|
+
if (!base || base === "/") return "";
|
|
1414
|
+
return base;
|
|
1415
|
+
}
|
|
1416
|
+
get url() {
|
|
1417
|
+
const url = this.location.pathname + this.location.search;
|
|
1418
|
+
if (this.base) return url.replace(this.base, "");
|
|
1419
|
+
return url;
|
|
1420
|
+
}
|
|
1421
|
+
pushState(path, replace) {
|
|
1422
|
+
const url = this.base + path;
|
|
1423
|
+
if (replace) this.history.replaceState({}, "", url);
|
|
1424
|
+
else this.history.pushState({}, "", url);
|
|
1425
|
+
}
|
|
1426
|
+
async invalidate(props) {
|
|
1427
|
+
const previous = [];
|
|
1428
|
+
this.log.trace("Invalidating layers");
|
|
1429
|
+
if (props) {
|
|
1430
|
+
const [key] = Object.keys(props);
|
|
1431
|
+
const value = props[key];
|
|
1432
|
+
for (const layer of this.state.layers) {
|
|
1433
|
+
if (layer.props?.[key]) {
|
|
1434
|
+
previous.push({
|
|
1435
|
+
...layer,
|
|
1436
|
+
props: {
|
|
1437
|
+
...layer.props,
|
|
1438
|
+
[key]: value
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
break;
|
|
1442
|
+
}
|
|
1443
|
+
previous.push(layer);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
await this.render({ previous });
|
|
1447
|
+
}
|
|
1448
|
+
async go(url, options = {}) {
|
|
1449
|
+
this.log.trace(`Going to ${url}`, {
|
|
1450
|
+
url,
|
|
1451
|
+
options
|
|
1452
|
+
});
|
|
1453
|
+
await this.render({
|
|
1454
|
+
url,
|
|
1455
|
+
previous: options.force ? [] : this.state.layers,
|
|
1456
|
+
meta: options.meta
|
|
1457
|
+
});
|
|
1458
|
+
if (this.state.url.pathname + this.state.url.search !== url) {
|
|
1459
|
+
this.pushState(this.state.url.pathname + this.state.url.search);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
this.pushState(url, options.replace);
|
|
1463
|
+
}
|
|
1464
|
+
async render(options = {}) {
|
|
1465
|
+
const previous = options.previous ?? this.state.layers;
|
|
1466
|
+
const url = options.url ?? this.url;
|
|
1467
|
+
const start = this.dateTimeProvider.now();
|
|
1468
|
+
this.transitioning = {
|
|
1469
|
+
to: url,
|
|
1470
|
+
from: this.state?.url.pathname
|
|
1471
|
+
};
|
|
1472
|
+
this.log.debug("Transitioning...", { to: url });
|
|
1473
|
+
const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous, options.meta);
|
|
1474
|
+
if (redirect) {
|
|
1475
|
+
this.log.info("Redirecting to", { redirect });
|
|
1476
|
+
if (redirect.startsWith("http")) window.location.href = redirect;
|
|
1477
|
+
else return await this.render({ url: redirect });
|
|
1478
|
+
}
|
|
1479
|
+
const ms = this.dateTimeProvider.now().diff(start);
|
|
1480
|
+
this.log.info(`Transition OK [${ms}ms]`, this.transitioning);
|
|
1481
|
+
this.transitioning = void 0;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Get embedded layers from the server.
|
|
1485
|
+
*/
|
|
1486
|
+
getHydrationState() {
|
|
1487
|
+
try {
|
|
1488
|
+
if ("__ssr" in window && typeof window.__ssr === "object") return window.__ssr;
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
console.error(error);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
onTransitionEnd = (0, alepha.$hook)({
|
|
1494
|
+
on: "react:transition:end",
|
|
1495
|
+
handler: () => {
|
|
1496
|
+
if (this.options.scrollRestoration === "top" && typeof window !== "undefined" && !this.alepha.isTest()) {
|
|
1497
|
+
this.log.trace("Restoring scroll position to top");
|
|
1498
|
+
window.scrollTo(0, 0);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
ready = (0, alepha.$hook)({
|
|
1503
|
+
on: "ready",
|
|
1504
|
+
handler: async () => {
|
|
1505
|
+
const hydration = this.getHydrationState();
|
|
1506
|
+
const previous = hydration?.layers ?? [];
|
|
1507
|
+
if (hydration) {
|
|
1508
|
+
for (const [key, value] of Object.entries(hydration)) if (key !== "layers") this.alepha.state.set(key, value);
|
|
1509
|
+
}
|
|
1510
|
+
await this.render({ previous });
|
|
1511
|
+
const element = this.router.root(this.state);
|
|
1512
|
+
await this.alepha.events.emit("react:browser:render", {
|
|
1513
|
+
element,
|
|
1514
|
+
root: this.getRootElement(),
|
|
1515
|
+
hydration,
|
|
1516
|
+
state: this.state
|
|
1517
|
+
});
|
|
1518
|
+
window.addEventListener("popstate", () => {
|
|
1519
|
+
if (this.base + this.state.url.pathname === this.location.pathname) return;
|
|
1520
|
+
this.log.debug("Popstate event triggered - rendering new state", { url: this.location.pathname + this.location.search });
|
|
1521
|
+
this.render();
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
};
|
|
1526
|
+
|
|
1527
|
+
//#endregion
|
|
1528
|
+
//#region src/core/services/ReactRouter.ts
|
|
1529
|
+
var ReactRouter = class {
|
|
1530
|
+
alepha = (0, alepha.$inject)(alepha.Alepha);
|
|
1531
|
+
pageApi = (0, alepha.$inject)(ReactPageProvider);
|
|
1532
|
+
get state() {
|
|
1533
|
+
return this.alepha.state.get("alepha.react.router.state");
|
|
1534
|
+
}
|
|
1535
|
+
get pages() {
|
|
1536
|
+
return this.pageApi.getPages();
|
|
1537
|
+
}
|
|
1538
|
+
get concretePages() {
|
|
1539
|
+
return this.pageApi.getConcretePages();
|
|
1540
|
+
}
|
|
1541
|
+
get browser() {
|
|
1542
|
+
if (this.alepha.isBrowser()) return this.alepha.inject(ReactBrowserProvider);
|
|
1543
|
+
}
|
|
1544
|
+
isActive(href, options = {}) {
|
|
1545
|
+
const current = this.state.url.pathname;
|
|
1546
|
+
let isActive = current === href || current === `${href}/` || `${current}/` === href;
|
|
1547
|
+
if (options.startWith && !isActive) isActive = current.startsWith(href);
|
|
1548
|
+
return isActive;
|
|
1549
|
+
}
|
|
1550
|
+
path(name, config = {}) {
|
|
1551
|
+
return this.pageApi.pathname(name, {
|
|
1552
|
+
params: {
|
|
1553
|
+
...this.state.params,
|
|
1554
|
+
...config.params
|
|
1555
|
+
},
|
|
1556
|
+
query: config.query
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Reload the current page.
|
|
1561
|
+
* This is equivalent to calling `go()` with the current pathname and search.
|
|
1562
|
+
*/
|
|
1563
|
+
async reload() {
|
|
1564
|
+
if (!this.browser) return;
|
|
1565
|
+
await this.go(this.location.pathname + this.location.search, {
|
|
1566
|
+
replace: true,
|
|
1567
|
+
force: true
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
getURL() {
|
|
1571
|
+
if (!this.browser) return this.state.url;
|
|
1572
|
+
return new URL(this.location.href);
|
|
1573
|
+
}
|
|
1574
|
+
get location() {
|
|
1575
|
+
if (!this.browser) throw new Error("Browser is required");
|
|
1576
|
+
return this.browser.location;
|
|
1577
|
+
}
|
|
1578
|
+
get current() {
|
|
1579
|
+
return this.state;
|
|
1580
|
+
}
|
|
1581
|
+
get pathname() {
|
|
1582
|
+
return this.state.url.pathname;
|
|
1583
|
+
}
|
|
1584
|
+
get query() {
|
|
1585
|
+
const query = {};
|
|
1586
|
+
for (const [key, value] of new URLSearchParams(this.state.url.search).entries()) query[key] = String(value);
|
|
1587
|
+
return query;
|
|
1588
|
+
}
|
|
1589
|
+
async back() {
|
|
1590
|
+
this.browser?.history.back();
|
|
1591
|
+
}
|
|
1592
|
+
async forward() {
|
|
1593
|
+
this.browser?.history.forward();
|
|
1594
|
+
}
|
|
1595
|
+
async invalidate(props) {
|
|
1596
|
+
await this.browser?.invalidate(props);
|
|
1597
|
+
}
|
|
1598
|
+
async go(path, options) {
|
|
1599
|
+
for (const page of this.pages) if (page.name === path) {
|
|
1600
|
+
await this.browser?.go(this.path(path, options), options);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
await this.browser?.go(path, options);
|
|
1604
|
+
}
|
|
1605
|
+
anchor(path, options = {}) {
|
|
1606
|
+
let href = path;
|
|
1607
|
+
for (const page of this.pages) if (page.name === path) {
|
|
1608
|
+
href = this.path(path, options);
|
|
1609
|
+
break;
|
|
1610
|
+
}
|
|
1611
|
+
return {
|
|
1612
|
+
href: this.base(href),
|
|
1613
|
+
onClick: (ev) => {
|
|
1614
|
+
ev.stopPropagation();
|
|
1615
|
+
ev.preventDefault();
|
|
1616
|
+
this.go(href, options).catch(console.error);
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
base(path) {
|
|
1621
|
+
const base = {}.env?.BASE_URL;
|
|
1622
|
+
if (!base || base === "/") return path;
|
|
1623
|
+
return base + path;
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Set query params.
|
|
1627
|
+
*
|
|
1628
|
+
* @param record
|
|
1629
|
+
* @param options
|
|
1630
|
+
*/
|
|
1631
|
+
setQueryParams(record, options = {}) {
|
|
1632
|
+
const func = typeof record === "function" ? record : () => record;
|
|
1633
|
+
const search = new URLSearchParams(func(this.query)).toString();
|
|
1634
|
+
const state = search ? `${this.pathname}?${search}` : this.pathname;
|
|
1635
|
+
if (options.push) window.history.pushState({}, "", state);
|
|
1636
|
+
else window.history.replaceState({}, "", state);
|
|
1637
|
+
}
|
|
1638
|
+
};
|
|
1639
|
+
|
|
1640
|
+
//#endregion
|
|
1641
|
+
//#region src/core/index.ts
|
|
1642
|
+
/**
|
|
1643
|
+
* Provides full-stack React development with declarative routing, server-side rendering, and client-side hydration.
|
|
1644
|
+
*
|
|
1645
|
+
* The React module enables building modern React applications using the `$page` descriptor on class properties.
|
|
1646
|
+
* It delivers seamless server-side rendering, automatic code splitting, and client-side navigation with full
|
|
1647
|
+
* type safety and schema validation for route parameters and data.
|
|
1648
|
+
*
|
|
1649
|
+
* @see {@link $page}
|
|
1650
|
+
* @module alepha.react
|
|
1651
|
+
*/
|
|
1652
|
+
const AlephaReact = (0, alepha.$module)({
|
|
1653
|
+
name: "alepha.react",
|
|
1654
|
+
descriptors: [$page],
|
|
1655
|
+
services: [
|
|
1656
|
+
ReactServerProvider,
|
|
1657
|
+
ReactPageProvider,
|
|
1658
|
+
ReactRouter,
|
|
1659
|
+
ReactPageService,
|
|
1660
|
+
ReactPageServerService
|
|
1661
|
+
],
|
|
1662
|
+
register: (alepha$1) => alepha$1.with(alepha_datetime.AlephaDateTime).with(alepha_server.AlephaServer).with(alepha_server_cache.AlephaServerCache).with(alepha_server_links.AlephaServerLinks).with({
|
|
1663
|
+
provide: ReactPageService,
|
|
1664
|
+
use: ReactPageServerService
|
|
1665
|
+
}).with(ReactServerProvider).with(ReactPageProvider).with(ReactRouter)
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
//#endregion
|
|
1669
|
+
//#region src/form/hooks/useFormState.ts
|
|
1670
|
+
const useFormState = (target, _events = [
|
|
1671
|
+
"loading",
|
|
1672
|
+
"dirty",
|
|
1673
|
+
"error"
|
|
1674
|
+
]) => {
|
|
1675
|
+
const alepha$1 = useAlepha();
|
|
1676
|
+
const events = _events;
|
|
1677
|
+
const [dirty, setDirty] = (0, react.useState)(false);
|
|
1678
|
+
const [loading, setLoading] = (0, react.useState)(false);
|
|
1679
|
+
const [error, setError] = (0, react.useState)(void 0);
|
|
1680
|
+
const [values, setValues] = (0, react.useState)(void 0);
|
|
1681
|
+
const form = "form" in target ? target.form : target;
|
|
1682
|
+
const path = "path" in target ? target.path : void 0;
|
|
1683
|
+
const hasValues = events.includes("values");
|
|
1684
|
+
const hasErrors = events.includes("error");
|
|
1685
|
+
const hasDirty = events.includes("dirty");
|
|
1686
|
+
const hasLoading = events.includes("loading");
|
|
1687
|
+
(0, react.useEffect)(() => {
|
|
1688
|
+
const listeners = [];
|
|
1689
|
+
if (hasErrors || hasValues || hasDirty) listeners.push(alepha$1.events.on("form:change", (event) => {
|
|
1690
|
+
if (event.id === form.id) {
|
|
1691
|
+
if (!path || event.path === path) {
|
|
1692
|
+
if (hasDirty) setDirty(true);
|
|
1693
|
+
if (hasErrors) setError(void 0);
|
|
1694
|
+
}
|
|
1695
|
+
if (hasValues) setValues(form.currentValues);
|
|
1696
|
+
}
|
|
1697
|
+
}));
|
|
1698
|
+
if (hasValues) listeners.push(alepha$1.events.on("form:reset", (event) => {
|
|
1699
|
+
if (event.id === form.id) setValues(event.values);
|
|
1700
|
+
}));
|
|
1701
|
+
if (hasLoading) listeners.push(alepha$1.events.on("form:submit:begin", (event) => {
|
|
1702
|
+
if (event.id === form.id) setLoading(true);
|
|
1703
|
+
}), alepha$1.events.on("form:submit:end", (event) => {
|
|
1704
|
+
if (event.id === form.id) setLoading(false);
|
|
1705
|
+
}));
|
|
1706
|
+
if (hasValues || hasDirty) listeners.push(alepha$1.events.on("form:submit:success", (event) => {
|
|
1707
|
+
if (event.id === form.id) {
|
|
1708
|
+
if (hasValues) setValues(event.values);
|
|
1709
|
+
if (hasDirty) setDirty(false);
|
|
1710
|
+
}
|
|
1711
|
+
}));
|
|
1712
|
+
if (hasErrors) listeners.push(alepha$1.events.on("form:submit:error", (event) => {
|
|
1713
|
+
if (event.id === form.id) {
|
|
1714
|
+
if (!path || event.error instanceof alepha.TypeBoxError && event.error.value.path === path) setError(event.error);
|
|
1715
|
+
}
|
|
1716
|
+
}));
|
|
1717
|
+
return () => {
|
|
1718
|
+
for (const unsub of listeners) unsub();
|
|
1719
|
+
};
|
|
1720
|
+
}, []);
|
|
1721
|
+
return {
|
|
1722
|
+
dirty,
|
|
1723
|
+
loading,
|
|
1724
|
+
error,
|
|
1725
|
+
values
|
|
1726
|
+
};
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
//#endregion
|
|
1730
|
+
//#region src/form/components/FormState.tsx
|
|
1731
|
+
const FormState = (props) => {
|
|
1732
|
+
const formState = useFormState(props.form);
|
|
1733
|
+
return props.children({
|
|
1734
|
+
loading: formState.loading,
|
|
1735
|
+
dirty: formState.dirty
|
|
1736
|
+
});
|
|
1737
|
+
};
|
|
1738
|
+
var FormState_default = FormState;
|
|
1739
|
+
|
|
1740
|
+
//#endregion
|
|
1741
|
+
//#region src/form/services/FormModel.ts
|
|
1742
|
+
/**
|
|
1743
|
+
* FormModel is a dynamic form handler that generates form inputs based on a provided TypeBox schema.
|
|
1744
|
+
* It manages form state, handles input changes, and processes form submissions with validation.
|
|
1745
|
+
*
|
|
1746
|
+
* It means to be injected and used within React components to provide a structured way to create and manage forms.
|
|
1747
|
+
*
|
|
1748
|
+
* @see {@link useForm}
|
|
1749
|
+
*/
|
|
1750
|
+
var FormModel = class {
|
|
1751
|
+
log = (0, alepha_logger.$logger)();
|
|
1752
|
+
alepha = (0, alepha.$inject)(alepha.Alepha);
|
|
1753
|
+
values = {};
|
|
1754
|
+
submitInProgress = false;
|
|
1755
|
+
input;
|
|
1756
|
+
get submitting() {
|
|
1757
|
+
return this.submitInProgress;
|
|
1758
|
+
}
|
|
1759
|
+
constructor(id, options) {
|
|
1760
|
+
this.id = id;
|
|
1761
|
+
this.options = options;
|
|
1762
|
+
this.options = options;
|
|
1763
|
+
if (options.initialValues) this.values = this.alepha.codec.decode(options.schema, options.initialValues);
|
|
1764
|
+
this.input = this.createProxyFromSchema(options, options.schema, {
|
|
1765
|
+
store: this.values,
|
|
1766
|
+
parent: ""
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
get element() {
|
|
1770
|
+
return window.document.getElementById(this.id);
|
|
1771
|
+
}
|
|
1772
|
+
get currentValues() {
|
|
1773
|
+
return this.restructureValues(this.values);
|
|
1774
|
+
}
|
|
1775
|
+
get props() {
|
|
1776
|
+
return {
|
|
1777
|
+
id: this.id,
|
|
1778
|
+
noValidate: true,
|
|
1779
|
+
onSubmit: (ev) => {
|
|
1780
|
+
ev?.preventDefault?.();
|
|
1781
|
+
this.submit();
|
|
1782
|
+
},
|
|
1783
|
+
onReset: (event) => this.reset(event)
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
reset = (event) => {
|
|
1787
|
+
for (const key in this.values) delete this.values[key];
|
|
1788
|
+
this.options.onReset?.();
|
|
1789
|
+
return this.alepha.events.emit("form:reset", {
|
|
1790
|
+
id: this.id,
|
|
1791
|
+
values: this.values
|
|
1792
|
+
}, { catch: true });
|
|
1793
|
+
};
|
|
1794
|
+
submit = async () => {
|
|
1795
|
+
if (this.submitInProgress) {
|
|
1796
|
+
this.log.warn("Form submission already in progress, ignoring duplicate submit.");
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
await this.alepha.events.emit("react:action:begin", {
|
|
1800
|
+
type: "form",
|
|
1801
|
+
id: this.id
|
|
1802
|
+
});
|
|
1803
|
+
await this.alepha.events.emit("form:submit:begin", { id: this.id });
|
|
1804
|
+
this.submitInProgress = true;
|
|
1805
|
+
const options = this.options;
|
|
1806
|
+
const args = { form: this.element };
|
|
1807
|
+
try {
|
|
1808
|
+
let values = this.restructureValues(this.values);
|
|
1809
|
+
if (alepha.t.schema.isSchema(options.schema)) values = this.alepha.codec.decode(options.schema, values);
|
|
1810
|
+
await options.handler(values, args);
|
|
1811
|
+
await this.alepha.events.emit("react:action:success", {
|
|
1812
|
+
type: "form",
|
|
1813
|
+
id: this.id
|
|
1814
|
+
});
|
|
1815
|
+
await this.alepha.events.emit("form:submit:success", {
|
|
1816
|
+
id: this.id,
|
|
1817
|
+
values
|
|
1818
|
+
});
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
this.log.error("Form submission error:", error);
|
|
1821
|
+
options.onError?.(error, args);
|
|
1822
|
+
await this.alepha.events.emit("react:action:error", {
|
|
1823
|
+
type: "form",
|
|
1824
|
+
id: this.id,
|
|
1825
|
+
error
|
|
1826
|
+
});
|
|
1827
|
+
await this.alepha.events.emit("form:submit:error", {
|
|
1828
|
+
error,
|
|
1829
|
+
id: this.id
|
|
1830
|
+
});
|
|
1831
|
+
} finally {
|
|
1832
|
+
this.submitInProgress = false;
|
|
1833
|
+
}
|
|
1834
|
+
await this.alepha.events.emit("react:action:end", {
|
|
1835
|
+
type: "form",
|
|
1836
|
+
id: this.id
|
|
1837
|
+
});
|
|
1838
|
+
await this.alepha.events.emit("form:submit:end", { id: this.id });
|
|
1839
|
+
};
|
|
1840
|
+
/**
|
|
1841
|
+
* Restructures flat keys like "address.city" into nested objects like { address: { city: ... } }
|
|
1842
|
+
* Values are already typed from onChange, so no conversion is needed.
|
|
1843
|
+
*/
|
|
1844
|
+
restructureValues(store) {
|
|
1845
|
+
const values = {};
|
|
1846
|
+
for (const [key, value] of Object.entries(store)) if (key.includes(".")) this.restructureNestedValue(values, key, value);
|
|
1847
|
+
else values[key] = value;
|
|
1848
|
+
return values;
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Helper to restructure a flat key like "address.city" into nested object structure.
|
|
1852
|
+
* The value is already typed, so we just assign it to the nested path.
|
|
1853
|
+
*/
|
|
1854
|
+
restructureNestedValue(values, key, value) {
|
|
1855
|
+
const pathSegments = key.split(".");
|
|
1856
|
+
const finalPropertyKey = pathSegments.pop();
|
|
1857
|
+
if (!finalPropertyKey) return;
|
|
1858
|
+
let currentObjectLevel = values;
|
|
1859
|
+
for (const segment of pathSegments) {
|
|
1860
|
+
currentObjectLevel[segment] ??= {};
|
|
1861
|
+
currentObjectLevel = currentObjectLevel[segment];
|
|
1862
|
+
}
|
|
1863
|
+
currentObjectLevel[finalPropertyKey] = value;
|
|
1864
|
+
}
|
|
1865
|
+
createProxyFromSchema(options, schema, context) {
|
|
1866
|
+
const parent = context.parent || "";
|
|
1867
|
+
return new Proxy({}, { get: (_, prop) => {
|
|
1868
|
+
if (!options.schema || !alepha.t.schema.isObject(schema)) return {};
|
|
1869
|
+
if (prop in schema.properties) {
|
|
1870
|
+
if (alepha.t.schema.isObject(schema.properties[prop])) return this.createProxyFromSchema(options, schema.properties[prop], {
|
|
1871
|
+
parent: parent ? `${parent}.${prop}` : prop,
|
|
1872
|
+
store: context.store
|
|
1873
|
+
});
|
|
1874
|
+
return this.createInputFromSchema(prop, options, schema, schema.required?.includes(prop) || false, context);
|
|
1875
|
+
}
|
|
1876
|
+
} });
|
|
1877
|
+
}
|
|
1878
|
+
createInputFromSchema(name, options, schema, required, context) {
|
|
1879
|
+
const parent = context.parent || "";
|
|
1880
|
+
const field = schema.properties?.[name];
|
|
1881
|
+
if (!field) return {
|
|
1882
|
+
path: "",
|
|
1883
|
+
required,
|
|
1884
|
+
props: {},
|
|
1885
|
+
schema,
|
|
1886
|
+
set: () => {},
|
|
1887
|
+
form: this
|
|
1888
|
+
};
|
|
1889
|
+
const isRequired = schema.required?.includes(name) ?? false;
|
|
1890
|
+
const key = parent ? `${parent}.${name}` : name;
|
|
1891
|
+
const path = `/${key.replaceAll(".", "/")}`;
|
|
1892
|
+
const set = (value, sync = true) => {
|
|
1893
|
+
const typedValue = this.getValueFromInput(value, field);
|
|
1894
|
+
if (context.store[key] === typedValue) {}
|
|
1895
|
+
context.store[key] = typedValue;
|
|
1896
|
+
if (options.onChange) options.onChange(key, typedValue, context.store);
|
|
1897
|
+
this.alepha.events.emit("form:change", {
|
|
1898
|
+
id: this.id,
|
|
1899
|
+
path,
|
|
1900
|
+
value: typedValue
|
|
1901
|
+
});
|
|
1902
|
+
if (sync) {
|
|
1903
|
+
const inputElement = window.document.querySelector(`[data-path="${path}"]`);
|
|
1904
|
+
if (inputElement instanceof HTMLInputElement) if (alepha.t.schema.isBoolean(field)) inputElement.checked = Boolean(value);
|
|
1905
|
+
else inputElement.value = value;
|
|
1906
|
+
}
|
|
1907
|
+
};
|
|
1908
|
+
const attr = {
|
|
1909
|
+
name: key,
|
|
1910
|
+
autoComplete: "off",
|
|
1911
|
+
onChange: (event) => {
|
|
1912
|
+
if (typeof event === "string") {
|
|
1913
|
+
set(event, false);
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
if (typeof event === "number") {
|
|
1917
|
+
set(event, false);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
if (alepha.t.schema.isBoolean(field)) set(event.target.checked, false);
|
|
1921
|
+
else set(event.target.value, false);
|
|
1922
|
+
}
|
|
1923
|
+
};
|
|
1924
|
+
attr["data-path"] = path;
|
|
1925
|
+
if (options.id) {
|
|
1926
|
+
attr.id = `${options.id}-${key}`;
|
|
1927
|
+
attr["data-testid"] = attr.id;
|
|
1928
|
+
}
|
|
1929
|
+
if (alepha.t.schema.isString(field)) {
|
|
1930
|
+
if (field.maxLength != null) attr.maxLength = Number(field.maxLength);
|
|
1931
|
+
if (field.minLength != null) attr.minLength = Number(field.minLength);
|
|
1932
|
+
}
|
|
1933
|
+
if (options.initialValues?.[name] != null) attr.defaultValue = this.valueToInputEntry(options.initialValues[name]);
|
|
1934
|
+
else if ("default" in field && field.default != null) attr.defaultValue = this.valueToInputEntry(field.default);
|
|
1935
|
+
if (isRequired) attr.required = true;
|
|
1936
|
+
if ("description" in field && typeof field.description === "string") attr["aria-label"] = field.description;
|
|
1937
|
+
if (alepha.t.schema.isInteger(field) || alepha.t.schema.isNumber(field)) attr.type = "number";
|
|
1938
|
+
else if (name === "password") attr.type = "password";
|
|
1939
|
+
else if (name === "email") attr.type = "email";
|
|
1940
|
+
else if (name === "url") attr.type = "url";
|
|
1941
|
+
else if (alepha.t.schema.isString(field)) if (field.format === "binary") attr.type = "file";
|
|
1942
|
+
else if (field.format === "date") attr.type = "date";
|
|
1943
|
+
else if (field.format === "time") attr.type = "time";
|
|
1944
|
+
else if (field.format === "date-time") attr.type = "datetime-local";
|
|
1945
|
+
else attr.type = "text";
|
|
1946
|
+
else if (alepha.t.schema.isBoolean(field)) attr.type = "checkbox";
|
|
1947
|
+
if (options.onCreateField) {
|
|
1948
|
+
const customAttr = options.onCreateField(name, field);
|
|
1949
|
+
Object.assign(attr, customAttr);
|
|
1950
|
+
}
|
|
1951
|
+
return {
|
|
1952
|
+
path,
|
|
1953
|
+
props: attr,
|
|
1954
|
+
schema: field,
|
|
1955
|
+
set,
|
|
1956
|
+
form: this,
|
|
1957
|
+
required
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Convert an input value to the correct type based on the schema.
|
|
1962
|
+
* Handles raw DOM values (strings, booleans from checkboxes, Files, etc.)
|
|
1963
|
+
*/
|
|
1964
|
+
getValueFromInput(input, schema) {
|
|
1965
|
+
if (input instanceof File) {
|
|
1966
|
+
if (alepha.t.schema.isString(schema) && schema.format === "binary") return input;
|
|
1967
|
+
return null;
|
|
1968
|
+
}
|
|
1969
|
+
if (alepha.t.schema.isBoolean(schema)) return !!input;
|
|
1970
|
+
if (alepha.t.schema.isNumber(schema)) {
|
|
1971
|
+
const num = Number(input);
|
|
1972
|
+
return Number.isNaN(num) ? null : num;
|
|
1973
|
+
}
|
|
1974
|
+
if (alepha.t.schema.isString(schema)) {
|
|
1975
|
+
if (schema.format === "date") return new Date(input).toISOString().slice(0, 10);
|
|
1976
|
+
if (schema.format === "time") return (/* @__PURE__ */ new Date(`1970-01-01T${input}`)).toISOString().slice(11, 16);
|
|
1977
|
+
if (schema.format === "date-time") return new Date(input).toISOString();
|
|
1978
|
+
return String(input);
|
|
1979
|
+
}
|
|
1980
|
+
return input;
|
|
1981
|
+
}
|
|
1982
|
+
valueToInputEntry(value) {
|
|
1983
|
+
if (value === null || value === void 0) return "";
|
|
1984
|
+
if (typeof value === "boolean") return value;
|
|
1985
|
+
if (typeof value === "number") return value;
|
|
1986
|
+
if (typeof value === "string") return value;
|
|
1987
|
+
if (value instanceof Date) return value.toISOString().slice(0, 16);
|
|
1988
|
+
return value;
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
|
|
1992
|
+
//#endregion
|
|
1993
|
+
//#region src/form/hooks/useForm.ts
|
|
1994
|
+
/**
|
|
1995
|
+
* Custom hook to create a form with validation and field management.
|
|
1996
|
+
* This hook uses TypeBox schemas to define the structure and validation rules for the form.
|
|
1997
|
+
* It provides a way to handle form submission, field creation, and value management.
|
|
1998
|
+
*
|
|
1999
|
+
* @example
|
|
2000
|
+
* ```tsx
|
|
2001
|
+
* import { t } from "alepha";
|
|
2002
|
+
*
|
|
2003
|
+
* const form = useForm({
|
|
2004
|
+
* schema: t.object({
|
|
2005
|
+
* username: t.text(),
|
|
2006
|
+
* password: t.text(),
|
|
2007
|
+
* }),
|
|
2008
|
+
* handler: (values) => {
|
|
2009
|
+
* console.log("Form submitted with values:", values);
|
|
2010
|
+
* },
|
|
2011
|
+
* });
|
|
2012
|
+
*
|
|
2013
|
+
* return (
|
|
2014
|
+
* <form {...form.props}>
|
|
2015
|
+
* <input {...form.input.username.props} />
|
|
2016
|
+
* <input {...form.input.password.props} />
|
|
2017
|
+
* <button type="submit">Submit</button>
|
|
2018
|
+
* </form>
|
|
2019
|
+
* );
|
|
2020
|
+
* ```
|
|
2021
|
+
*/
|
|
2022
|
+
const useForm = (options, deps = []) => {
|
|
2023
|
+
const alepha$1 = useAlepha();
|
|
2024
|
+
const formId = (0, react.useId)();
|
|
2025
|
+
return (0, react.useMemo)(() => {
|
|
2026
|
+
return alepha$1.inject(FormModel, {
|
|
2027
|
+
lifetime: "transient",
|
|
2028
|
+
args: [options.id || formId, options]
|
|
2029
|
+
});
|
|
2030
|
+
}, deps);
|
|
2031
|
+
};
|
|
2032
|
+
|
|
2033
|
+
//#endregion
|
|
2034
|
+
//#region src/form/index.ts
|
|
2035
|
+
/**
|
|
2036
|
+
* React hooks for managing forms in Alepha applications.
|
|
2037
|
+
*
|
|
2038
|
+
* This module provides a set of hooks to simplify form handling, validation, and submission in React applications built with Alepha.
|
|
2039
|
+
*
|
|
2040
|
+
* It includes:
|
|
2041
|
+
* - `useForm`: A hook for managing form state, validation, and submission.
|
|
2042
|
+
*
|
|
2043
|
+
* @see {@link useForm}
|
|
2044
|
+
* @module alepha.react.form
|
|
2045
|
+
*/
|
|
2046
|
+
const AlephaReactForm = (0, alepha.$module)({ name: "alepha.react.form" });
|
|
2047
|
+
|
|
2048
|
+
//#endregion
|
|
2049
|
+
exports.AlephaReactForm = AlephaReactForm;
|
|
2050
|
+
exports.FormModel = FormModel;
|
|
2051
|
+
exports.FormState = FormState_default;
|
|
2052
|
+
exports.useForm = useForm;
|
|
2053
|
+
exports.useFormState = useFormState;
|
|
2054
|
+
//# sourceMappingURL=index.cjs.map
|