@alepha/react 0.5.0 → 0.5.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/dist/index.browser.cjs +34 -0
- package/dist/index.browser.mjs +17 -0
- package/dist/index.cjs +500 -0
- package/dist/index.d.cts +872 -0
- package/dist/index.d.mts +872 -0
- package/dist/index.mjs +477 -0
- package/dist/useRouterState-BlKHWZwk.cjs +944 -0
- package/dist/useRouterState-CvFCmaq7.mjs +926 -0
- package/package.json +5 -5
- package/test/$page.spec.tsx +0 -42
- package/test/Router.spec.tsx +0 -138
- package/tsconfig.json +0 -6
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
import { __descriptor, NotImplementedError, KIND, EventEmitter, $logger, $inject, Alepha, $hook } from '@alepha/core';
|
|
2
|
+
import { createContext, useContext, useState, useEffect, createElement, useMemo } from 'react';
|
|
3
|
+
import { HttpClient } from '@alepha/server';
|
|
4
|
+
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
5
|
+
import { compile, match } from 'path-to-regexp';
|
|
6
|
+
|
|
7
|
+
const pageDescriptorKey = "PAGE";
|
|
8
|
+
const $page = (options) => {
|
|
9
|
+
__descriptor(pageDescriptorKey);
|
|
10
|
+
return {
|
|
11
|
+
[KIND]: pageDescriptorKey,
|
|
12
|
+
options,
|
|
13
|
+
render: () => {
|
|
14
|
+
throw new NotImplementedError(pageDescriptorKey);
|
|
15
|
+
},
|
|
16
|
+
go: () => {
|
|
17
|
+
throw new NotImplementedError(pageDescriptorKey);
|
|
18
|
+
},
|
|
19
|
+
createAnchorProps: () => {
|
|
20
|
+
throw new NotImplementedError(pageDescriptorKey);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
$page[KIND] = pageDescriptorKey;
|
|
25
|
+
|
|
26
|
+
const RouterContext = createContext(
|
|
27
|
+
void 0
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const RouterLayerContext = createContext(void 0);
|
|
31
|
+
|
|
32
|
+
const NestedView = (props) => {
|
|
33
|
+
const app = useContext(RouterContext);
|
|
34
|
+
const layer = useContext(RouterLayerContext);
|
|
35
|
+
const index = layer?.index ?? 0;
|
|
36
|
+
const [view, setView] = useState(
|
|
37
|
+
app?.state.layers[index]?.element
|
|
38
|
+
);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (app?.alepha.isBrowser()) {
|
|
41
|
+
return app?.router.on("end", ({ layers }) => {
|
|
42
|
+
setView(layers[index]?.element);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}, [app]);
|
|
46
|
+
return view ?? props.children ?? null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
class RedirectException extends Error {
|
|
50
|
+
constructor(page) {
|
|
51
|
+
super("Redirection");
|
|
52
|
+
this.page = page;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
class Router extends EventEmitter {
|
|
56
|
+
log = $logger();
|
|
57
|
+
alepha = $inject(Alepha);
|
|
58
|
+
pages = [];
|
|
59
|
+
notFoundPageRoute;
|
|
60
|
+
/**
|
|
61
|
+
* Get the page by name.
|
|
62
|
+
*
|
|
63
|
+
* @param name - Page name
|
|
64
|
+
* @return PageRoute
|
|
65
|
+
*/
|
|
66
|
+
page(name) {
|
|
67
|
+
const found = this.pages.find((it) => it.name === name);
|
|
68
|
+
if (!found) {
|
|
69
|
+
throw new Error(`Page ${name} not found`);
|
|
70
|
+
}
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
*
|
|
75
|
+
*/
|
|
76
|
+
root(state, opts = {}) {
|
|
77
|
+
return createElement(
|
|
78
|
+
RouterContext.Provider,
|
|
79
|
+
{
|
|
80
|
+
value: {
|
|
81
|
+
state,
|
|
82
|
+
router: this,
|
|
83
|
+
alepha: this.alepha,
|
|
84
|
+
session: opts.user ? { user: opts.user } : void 0
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
state.layers[0]?.element
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
*
|
|
92
|
+
* @param url
|
|
93
|
+
* @param options
|
|
94
|
+
*/
|
|
95
|
+
async render(url, options = {}) {
|
|
96
|
+
const [pathname, search = ""] = url.split("?");
|
|
97
|
+
const state = {
|
|
98
|
+
pathname,
|
|
99
|
+
search,
|
|
100
|
+
layers: []
|
|
101
|
+
};
|
|
102
|
+
this.emit("begin", void 0);
|
|
103
|
+
try {
|
|
104
|
+
let layers = await this.match(url, options);
|
|
105
|
+
if (layers.length === 0) {
|
|
106
|
+
if (this.notFoundPageRoute) {
|
|
107
|
+
layers = await this.createLayers(url, this.notFoundPageRoute);
|
|
108
|
+
} else {
|
|
109
|
+
layers.push({
|
|
110
|
+
name: "not-found",
|
|
111
|
+
element: "Not Found",
|
|
112
|
+
index: 0,
|
|
113
|
+
path: "/"
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
state.layers = layers;
|
|
118
|
+
this.emit("success", void 0);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
if (e instanceof RedirectException) {
|
|
121
|
+
return {
|
|
122
|
+
element: null,
|
|
123
|
+
layers: [],
|
|
124
|
+
redirect: typeof e.page === "string" ? e.page : this.href(e.page)
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
this.log.error(e);
|
|
128
|
+
state.layers = [
|
|
129
|
+
{
|
|
130
|
+
name: "error",
|
|
131
|
+
element: this.renderError(e),
|
|
132
|
+
index: 0,
|
|
133
|
+
path: "/"
|
|
134
|
+
}
|
|
135
|
+
];
|
|
136
|
+
this.emit("error", e);
|
|
137
|
+
}
|
|
138
|
+
if (options.state) {
|
|
139
|
+
options.state.layers = state.layers;
|
|
140
|
+
options.state.pathname = state.pathname;
|
|
141
|
+
options.state.search = state.search;
|
|
142
|
+
this.emit("end", options.state);
|
|
143
|
+
return {
|
|
144
|
+
element: this.root(options.state, options),
|
|
145
|
+
layers: options.state.layers
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
this.emit("end", state);
|
|
149
|
+
return {
|
|
150
|
+
element: this.root(state, options),
|
|
151
|
+
layers: state.layers
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
*
|
|
156
|
+
* @param url
|
|
157
|
+
* @param options
|
|
158
|
+
* @protected
|
|
159
|
+
*/
|
|
160
|
+
async match(url, options = {}) {
|
|
161
|
+
const pages = this.pages;
|
|
162
|
+
const previous = options.previous;
|
|
163
|
+
const [pathname, search] = url.split("?");
|
|
164
|
+
for (const route of pages) {
|
|
165
|
+
if (route.children?.find((it) => !it.path || it.path === "/")) continue;
|
|
166
|
+
if (!route.match) continue;
|
|
167
|
+
const match2 = route.match.exec(pathname);
|
|
168
|
+
if (match2) {
|
|
169
|
+
const params = match2.params ?? {};
|
|
170
|
+
const query = {};
|
|
171
|
+
if (search) {
|
|
172
|
+
for (const [key, value] of new URLSearchParams(search).entries()) {
|
|
173
|
+
query[key] = String(value);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return await this.createLayers(
|
|
177
|
+
url,
|
|
178
|
+
route,
|
|
179
|
+
params,
|
|
180
|
+
query,
|
|
181
|
+
previous,
|
|
182
|
+
options.user
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Create layers for the given route.
|
|
190
|
+
*
|
|
191
|
+
* @param route
|
|
192
|
+
* @param params
|
|
193
|
+
* @param query
|
|
194
|
+
* @param previous
|
|
195
|
+
* @param user
|
|
196
|
+
* @protected
|
|
197
|
+
*/
|
|
198
|
+
async createLayers(url, route, params = {}, query = {}, previous = [], user) {
|
|
199
|
+
const layers = [];
|
|
200
|
+
let context = {};
|
|
201
|
+
const stack = [{ route }];
|
|
202
|
+
let parent = route.parent;
|
|
203
|
+
while (parent) {
|
|
204
|
+
stack.unshift({ route: parent });
|
|
205
|
+
parent = parent.parent;
|
|
206
|
+
}
|
|
207
|
+
let forceRefresh = false;
|
|
208
|
+
for (let i = 0; i < stack.length; i++) {
|
|
209
|
+
const it = stack[i];
|
|
210
|
+
const route2 = it.route;
|
|
211
|
+
const config = {};
|
|
212
|
+
try {
|
|
213
|
+
config.query = route2.schema?.query ? this.alepha.parse(route2.schema.query, query) : {};
|
|
214
|
+
} catch (e) {
|
|
215
|
+
it.error = e;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
config.params = route2.schema?.params ? this.alepha.parse(route2.schema.params, params) : {};
|
|
220
|
+
} catch (e) {
|
|
221
|
+
it.error = e;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
it.config = {
|
|
225
|
+
...config
|
|
226
|
+
};
|
|
227
|
+
if (!route2.resolve) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (previous?.[i] && !forceRefresh && previous[i].name === route2.name) {
|
|
231
|
+
const url2 = (str) => str ? str.replace(/\/\/+/g, "/") : "/";
|
|
232
|
+
const prev = JSON.stringify({
|
|
233
|
+
part: url2(previous[i].part),
|
|
234
|
+
params: previous[i].config?.params ?? {}
|
|
235
|
+
});
|
|
236
|
+
const curr = JSON.stringify({
|
|
237
|
+
part: url2(route2.path),
|
|
238
|
+
params: config.params ?? {}
|
|
239
|
+
});
|
|
240
|
+
if (prev === curr) {
|
|
241
|
+
it.props = previous[i].props;
|
|
242
|
+
context = {
|
|
243
|
+
...context,
|
|
244
|
+
...it.props
|
|
245
|
+
};
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
forceRefresh = true;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const props = await route2.resolve?.({
|
|
252
|
+
...config,
|
|
253
|
+
...context,
|
|
254
|
+
user,
|
|
255
|
+
url
|
|
256
|
+
}) ?? {};
|
|
257
|
+
it.props = {
|
|
258
|
+
...props
|
|
259
|
+
};
|
|
260
|
+
context = {
|
|
261
|
+
...context,
|
|
262
|
+
...props
|
|
263
|
+
};
|
|
264
|
+
} catch (e) {
|
|
265
|
+
if (e instanceof RedirectException) {
|
|
266
|
+
throw e;
|
|
267
|
+
}
|
|
268
|
+
this.log.error(e);
|
|
269
|
+
it.error = e;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
let acc = "";
|
|
274
|
+
for (let i = 0; i < stack.length; i++) {
|
|
275
|
+
const it = stack[i];
|
|
276
|
+
const props = it.props ?? {};
|
|
277
|
+
const params2 = { ...it.config?.params };
|
|
278
|
+
for (const key of Object.keys(params2)) {
|
|
279
|
+
params2[key] = String(params2[key]);
|
|
280
|
+
}
|
|
281
|
+
acc += "/";
|
|
282
|
+
acc += it.route.path ? compile(it.route.path)(params2) : "";
|
|
283
|
+
const path = acc.replace(/\/+/, "/");
|
|
284
|
+
if (it.error) {
|
|
285
|
+
const errorHandler = this.getErrorHandler(it.route);
|
|
286
|
+
const element = errorHandler ? errorHandler({
|
|
287
|
+
...it.config,
|
|
288
|
+
error: it.error,
|
|
289
|
+
url
|
|
290
|
+
}) : this.renderError(it.error);
|
|
291
|
+
layers.push({
|
|
292
|
+
props,
|
|
293
|
+
name: it.route.name,
|
|
294
|
+
part: it.route.path,
|
|
295
|
+
config: it.config,
|
|
296
|
+
element: this.renderView(i + 1, path, element),
|
|
297
|
+
index: i + 1,
|
|
298
|
+
path
|
|
299
|
+
});
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
const layer = await this.createElement(it.route, {
|
|
303
|
+
...props,
|
|
304
|
+
...context
|
|
305
|
+
});
|
|
306
|
+
layers.push({
|
|
307
|
+
name: it.route.name,
|
|
308
|
+
props,
|
|
309
|
+
part: it.route.path,
|
|
310
|
+
config: it.config,
|
|
311
|
+
element: this.renderView(i + 1, path, layer),
|
|
312
|
+
index: i + 1,
|
|
313
|
+
path
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return layers;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
*
|
|
320
|
+
* @param route
|
|
321
|
+
* @protected
|
|
322
|
+
*/
|
|
323
|
+
getErrorHandler(route) {
|
|
324
|
+
if (route.errorHandler) return route.errorHandler;
|
|
325
|
+
let parent = route.parent;
|
|
326
|
+
while (parent) {
|
|
327
|
+
if (parent.errorHandler) return parent.errorHandler;
|
|
328
|
+
parent = parent.parent;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
*
|
|
333
|
+
* @param page
|
|
334
|
+
* @param props
|
|
335
|
+
* @protected
|
|
336
|
+
*/
|
|
337
|
+
async createElement(page, props) {
|
|
338
|
+
if (page.lazy) {
|
|
339
|
+
const component = await page.lazy();
|
|
340
|
+
return createElement(component.default, props);
|
|
341
|
+
}
|
|
342
|
+
if (page.component) {
|
|
343
|
+
return createElement(page.component, props);
|
|
344
|
+
}
|
|
345
|
+
return void 0;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
*
|
|
349
|
+
* @param e
|
|
350
|
+
* @protected
|
|
351
|
+
*/
|
|
352
|
+
renderError(e) {
|
|
353
|
+
return createElement("pre", { style: { overflow: "auto" } }, `${e.stack}`);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Render an empty view.
|
|
357
|
+
*
|
|
358
|
+
* @protected
|
|
359
|
+
*/
|
|
360
|
+
renderEmptyView() {
|
|
361
|
+
return createElement(NestedView, {});
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Create a valid href for the given page.
|
|
365
|
+
* @param page
|
|
366
|
+
* @param params
|
|
367
|
+
*/
|
|
368
|
+
href(page, params = {}) {
|
|
369
|
+
const found = this.pages.find((it) => it.name === page.options.name);
|
|
370
|
+
if (!found) {
|
|
371
|
+
throw new Error(`Page ${page.options.name} not found`);
|
|
372
|
+
}
|
|
373
|
+
let url = found.path ?? "";
|
|
374
|
+
let parent = found.parent;
|
|
375
|
+
while (parent) {
|
|
376
|
+
url = `${parent.path ?? ""}/${url}`;
|
|
377
|
+
parent = parent.parent;
|
|
378
|
+
}
|
|
379
|
+
url = compile(url)(params);
|
|
380
|
+
return url.replace(/\/\/+/g, "/") || "/";
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
*
|
|
384
|
+
* @param index
|
|
385
|
+
* @param path
|
|
386
|
+
* @param view
|
|
387
|
+
* @protected
|
|
388
|
+
*/
|
|
389
|
+
renderView(index, path, view = this.renderEmptyView()) {
|
|
390
|
+
return createElement(
|
|
391
|
+
RouterLayerContext.Provider,
|
|
392
|
+
{
|
|
393
|
+
value: {
|
|
394
|
+
index,
|
|
395
|
+
path
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
view
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
*
|
|
403
|
+
* @param entry
|
|
404
|
+
*/
|
|
405
|
+
add(entry) {
|
|
406
|
+
if (this.alepha.isReady()) {
|
|
407
|
+
throw new Error("Router is already initialized");
|
|
408
|
+
}
|
|
409
|
+
if (entry.notFoundHandler) {
|
|
410
|
+
this.notFoundPageRoute = {
|
|
411
|
+
name: "not-found",
|
|
412
|
+
component: entry.notFoundHandler
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
entry.name ??= this.nextId();
|
|
416
|
+
const page = entry;
|
|
417
|
+
page.match = this.createMatchFunction(page);
|
|
418
|
+
this.pages.push(page);
|
|
419
|
+
if (page.children) {
|
|
420
|
+
for (const child of page.children) {
|
|
421
|
+
child.parent = page;
|
|
422
|
+
this.add(child);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Create a match function for the given page.
|
|
428
|
+
*
|
|
429
|
+
* @param page
|
|
430
|
+
* @protected
|
|
431
|
+
*/
|
|
432
|
+
createMatchFunction(page) {
|
|
433
|
+
let url = page.path ?? "/";
|
|
434
|
+
let target = page.parent;
|
|
435
|
+
while (target) {
|
|
436
|
+
url = `${target.path ?? ""}/${url}`;
|
|
437
|
+
target = target.parent;
|
|
438
|
+
}
|
|
439
|
+
let path = url.replace(/\/\/+/g, "/");
|
|
440
|
+
if (path.endsWith("/")) {
|
|
441
|
+
path = path.slice(0, -1);
|
|
442
|
+
}
|
|
443
|
+
if (path.includes("?")) {
|
|
444
|
+
return {
|
|
445
|
+
exec: match(path.split("?")[0]),
|
|
446
|
+
path
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
exec: match(path),
|
|
451
|
+
path
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
*
|
|
456
|
+
*/
|
|
457
|
+
empty() {
|
|
458
|
+
return this.pages.length === 0;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
*
|
|
462
|
+
* @protected
|
|
463
|
+
*/
|
|
464
|
+
_next = 0;
|
|
465
|
+
/**
|
|
466
|
+
*
|
|
467
|
+
* @protected
|
|
468
|
+
*/
|
|
469
|
+
nextId() {
|
|
470
|
+
this._next += 1;
|
|
471
|
+
return `P${this._next}`;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
class PageDescriptorProvider {
|
|
476
|
+
alepha = $inject(Alepha);
|
|
477
|
+
router = $inject(Router);
|
|
478
|
+
configure = $hook({
|
|
479
|
+
name: "configure",
|
|
480
|
+
handler: () => {
|
|
481
|
+
const pages = this.alepha.getDescriptorValues($page);
|
|
482
|
+
for (const { value, key } of pages) {
|
|
483
|
+
value.options.name ??= key;
|
|
484
|
+
if (pages.find((it) => it.value.options.children?.().includes(value))) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
this.router.add(this.map(pages, value));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
/**
|
|
492
|
+
* Transform
|
|
493
|
+
* @param pages
|
|
494
|
+
* @param target
|
|
495
|
+
* @protected
|
|
496
|
+
*/
|
|
497
|
+
map(pages, target) {
|
|
498
|
+
const children = target.options.children?.() ?? [];
|
|
499
|
+
for (const it of pages) {
|
|
500
|
+
if (it.value.options.parent === target) {
|
|
501
|
+
children.push(it.value);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
...target.options,
|
|
506
|
+
parent: void 0,
|
|
507
|
+
children: children.map((it) => this.map(pages, it))
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
class ReactBrowserProvider {
|
|
513
|
+
log = $logger();
|
|
514
|
+
client = $inject(HttpClient);
|
|
515
|
+
router = $inject(Router);
|
|
516
|
+
root;
|
|
517
|
+
transitioning;
|
|
518
|
+
state = { layers: [], pathname: "", search: "" };
|
|
519
|
+
/**
|
|
520
|
+
*
|
|
521
|
+
*/
|
|
522
|
+
get document() {
|
|
523
|
+
return window.document;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
*
|
|
527
|
+
*/
|
|
528
|
+
get history() {
|
|
529
|
+
return window.history;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
*
|
|
533
|
+
*/
|
|
534
|
+
get url() {
|
|
535
|
+
return window.location.pathname + window.location.search;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
*
|
|
539
|
+
* @param props
|
|
540
|
+
*/
|
|
541
|
+
async invalidate(props) {
|
|
542
|
+
const previous = [];
|
|
543
|
+
if (props) {
|
|
544
|
+
const [key] = Object.keys(props);
|
|
545
|
+
const value = props[key];
|
|
546
|
+
for (const layer of this.state.layers) {
|
|
547
|
+
if (layer.props?.[key]) {
|
|
548
|
+
previous.push({
|
|
549
|
+
...layer,
|
|
550
|
+
props: {
|
|
551
|
+
...layer.props,
|
|
552
|
+
[key]: value
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
previous.push(layer);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
await this.render({ previous });
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
*
|
|
564
|
+
* @param url
|
|
565
|
+
* @param options
|
|
566
|
+
*/
|
|
567
|
+
async go(url, options = {}) {
|
|
568
|
+
const result = await this.render({
|
|
569
|
+
url
|
|
570
|
+
});
|
|
571
|
+
if (result.url !== url) {
|
|
572
|
+
this.history.replaceState({}, "", result.url);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (options.replace) {
|
|
576
|
+
this.history.replaceState({}, "", url);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
this.history.pushState({}, "", url);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
*
|
|
583
|
+
* @param options
|
|
584
|
+
* @protected
|
|
585
|
+
*/
|
|
586
|
+
async render(options = {}) {
|
|
587
|
+
const previous = options.previous ?? this.state.layers;
|
|
588
|
+
const url = options.url ?? this.url;
|
|
589
|
+
this.transitioning = { to: url };
|
|
590
|
+
const result = await this.router.render(url, {
|
|
591
|
+
previous,
|
|
592
|
+
state: this.state
|
|
593
|
+
});
|
|
594
|
+
if (result.redirect) {
|
|
595
|
+
return await this.render({ url: result.redirect });
|
|
596
|
+
}
|
|
597
|
+
this.transitioning = void 0;
|
|
598
|
+
return { url };
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Get embedded layers from the server.
|
|
602
|
+
*
|
|
603
|
+
* @protected
|
|
604
|
+
*/
|
|
605
|
+
getEmbeddedCache() {
|
|
606
|
+
try {
|
|
607
|
+
if ("__ssr" in window && typeof window.__ssr === "object") {
|
|
608
|
+
return window.__ssr;
|
|
609
|
+
}
|
|
610
|
+
} catch (error) {
|
|
611
|
+
console.error(error);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
*
|
|
616
|
+
* @protected
|
|
617
|
+
*/
|
|
618
|
+
getRootElement() {
|
|
619
|
+
const root = this.document.getElementById("root");
|
|
620
|
+
if (root) {
|
|
621
|
+
return root;
|
|
622
|
+
}
|
|
623
|
+
const div = this.document.createElement("div");
|
|
624
|
+
div.id = "root";
|
|
625
|
+
this.document.body.appendChild(div);
|
|
626
|
+
return div;
|
|
627
|
+
}
|
|
628
|
+
// -------------------------------------------------------------------------------------------------------------------
|
|
629
|
+
/**
|
|
630
|
+
*
|
|
631
|
+
* @protected
|
|
632
|
+
*/
|
|
633
|
+
ready = $hook({
|
|
634
|
+
name: "ready",
|
|
635
|
+
handler: async () => {
|
|
636
|
+
const cache = this.getEmbeddedCache();
|
|
637
|
+
const previous = cache?.layers ?? [];
|
|
638
|
+
const session = cache?.session ?? await this.client.of().session();
|
|
639
|
+
await this.render({ previous });
|
|
640
|
+
const element = this.router.root(this.state, session);
|
|
641
|
+
if (previous.length > 0) {
|
|
642
|
+
this.root = hydrateRoot(this.getRootElement(), element);
|
|
643
|
+
this.log.info("Hydrated root element");
|
|
644
|
+
} else {
|
|
645
|
+
this.root = createRoot(this.getRootElement());
|
|
646
|
+
this.root.render(element);
|
|
647
|
+
this.log.info("Created root element");
|
|
648
|
+
}
|
|
649
|
+
window.addEventListener("popstate", () => {
|
|
650
|
+
this.render();
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
/**
|
|
655
|
+
*
|
|
656
|
+
* @protected
|
|
657
|
+
*/
|
|
658
|
+
stop = $hook({
|
|
659
|
+
name: "stop",
|
|
660
|
+
handler: async () => {
|
|
661
|
+
if (this.root) {
|
|
662
|
+
this.root.unmount();
|
|
663
|
+
this.log.info("Unmounted root element");
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
class RouterHookApi {
|
|
670
|
+
constructor(state, layer, browser) {
|
|
671
|
+
this.state = state;
|
|
672
|
+
this.layer = layer;
|
|
673
|
+
this.browser = browser;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
*
|
|
677
|
+
*/
|
|
678
|
+
get current() {
|
|
679
|
+
return this.state;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
*
|
|
683
|
+
*/
|
|
684
|
+
get pathname() {
|
|
685
|
+
return this.state.pathname;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
*
|
|
689
|
+
*/
|
|
690
|
+
get query() {
|
|
691
|
+
const query = {};
|
|
692
|
+
for (const [key, value] of new URLSearchParams(
|
|
693
|
+
this.state.search
|
|
694
|
+
).entries()) {
|
|
695
|
+
query[key] = String(value);
|
|
696
|
+
}
|
|
697
|
+
return query;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
*
|
|
701
|
+
*/
|
|
702
|
+
async back() {
|
|
703
|
+
this.browser?.history.back();
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
*
|
|
707
|
+
*/
|
|
708
|
+
async forward() {
|
|
709
|
+
this.browser?.history.forward();
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
*
|
|
713
|
+
* @param props
|
|
714
|
+
*/
|
|
715
|
+
async invalidate(props) {
|
|
716
|
+
await this.browser?.invalidate(props);
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Create a valid href for the given pathname.
|
|
720
|
+
*
|
|
721
|
+
* @param pathname
|
|
722
|
+
* @param layer
|
|
723
|
+
*/
|
|
724
|
+
createHref(pathname, layer = this.layer) {
|
|
725
|
+
if (typeof pathname === "object") {
|
|
726
|
+
pathname = pathname.options.path ?? "";
|
|
727
|
+
}
|
|
728
|
+
return pathname.startsWith("/") ? pathname : `${layer.path}/${pathname}`.replace(/\/\/+/g, "/");
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
*
|
|
732
|
+
* @param path
|
|
733
|
+
* @param options
|
|
734
|
+
*/
|
|
735
|
+
async go(path, options = {}) {
|
|
736
|
+
return await this.browser?.go(this.createHref(path, this.layer), options);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
*
|
|
740
|
+
* @param path
|
|
741
|
+
*/
|
|
742
|
+
createAnchorProps(path) {
|
|
743
|
+
const href = this.createHref(path, this.layer);
|
|
744
|
+
return {
|
|
745
|
+
href,
|
|
746
|
+
onClick: (ev) => {
|
|
747
|
+
ev.stopPropagation();
|
|
748
|
+
ev.preventDefault();
|
|
749
|
+
this.go(path).catch(console.error);
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Set query params.
|
|
755
|
+
*
|
|
756
|
+
* @param record
|
|
757
|
+
* @param options
|
|
758
|
+
*/
|
|
759
|
+
setQueryParams(record, options = {}) {
|
|
760
|
+
const search = new URLSearchParams(
|
|
761
|
+
options.merge ? {
|
|
762
|
+
...this.query,
|
|
763
|
+
...record
|
|
764
|
+
} : {
|
|
765
|
+
...record
|
|
766
|
+
}
|
|
767
|
+
).toString();
|
|
768
|
+
const state = search ? `${this.pathname}?${search}` : this.pathname;
|
|
769
|
+
if (options.push) {
|
|
770
|
+
window.history.pushState({}, "", state);
|
|
771
|
+
} else {
|
|
772
|
+
window.history.replaceState({}, "", state);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const useRouter = () => {
|
|
778
|
+
const ctx = useContext(RouterContext);
|
|
779
|
+
const layer = useContext(RouterLayerContext);
|
|
780
|
+
if (!ctx || !layer) {
|
|
781
|
+
throw new Error("useRouter must be used within a RouterProvider");
|
|
782
|
+
}
|
|
783
|
+
return useMemo(
|
|
784
|
+
() => new RouterHookApi(
|
|
785
|
+
ctx.state,
|
|
786
|
+
layer,
|
|
787
|
+
ctx.alepha.isBrowser() ? ctx.alepha.get(ReactBrowserProvider) : void 0
|
|
788
|
+
),
|
|
789
|
+
[ctx.router, layer]
|
|
790
|
+
);
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const useActive = (path) => {
|
|
794
|
+
const router = useRouter();
|
|
795
|
+
const ctx = useContext(RouterContext);
|
|
796
|
+
const layer = useContext(RouterLayerContext);
|
|
797
|
+
if (!ctx || !layer) {
|
|
798
|
+
throw new Error("useRouter must be used within a RouterProvider");
|
|
799
|
+
}
|
|
800
|
+
let name;
|
|
801
|
+
if (typeof path === "object" && path.options.name) {
|
|
802
|
+
name = path.options.name;
|
|
803
|
+
}
|
|
804
|
+
const [current, setCurrent] = useState(ctx.state.pathname);
|
|
805
|
+
const href = useMemo(() => router.createHref(path, layer), [path, layer]);
|
|
806
|
+
const [isPending, setPending] = useState(false);
|
|
807
|
+
const isActive = current === href;
|
|
808
|
+
useEffect(
|
|
809
|
+
() => ctx.router.on("end", ({ pathname }) => setCurrent(pathname)),
|
|
810
|
+
[]
|
|
811
|
+
);
|
|
812
|
+
return {
|
|
813
|
+
name,
|
|
814
|
+
isPending,
|
|
815
|
+
isActive,
|
|
816
|
+
anchorProps: {
|
|
817
|
+
href,
|
|
818
|
+
onClick: (ev) => {
|
|
819
|
+
ev.stopPropagation();
|
|
820
|
+
ev.preventDefault();
|
|
821
|
+
if (isActive) return;
|
|
822
|
+
if (isPending) return;
|
|
823
|
+
setPending(true);
|
|
824
|
+
router.go(href).then(() => {
|
|
825
|
+
setPending(false);
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
const useInject = (classEntry) => {
|
|
833
|
+
const ctx = useContext(RouterContext);
|
|
834
|
+
if (!ctx) {
|
|
835
|
+
throw new Error("useRouter must be used within a <RouterProvider>");
|
|
836
|
+
}
|
|
837
|
+
return ctx.alepha.get(classEntry);
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const useClient = () => {
|
|
841
|
+
return useInject(HttpClient);
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
const useQueryParams = (schema, options = {}) => {
|
|
845
|
+
const ctx = useContext(RouterContext);
|
|
846
|
+
if (!ctx) {
|
|
847
|
+
throw new Error("useQueryParams must be used within a RouterProvider");
|
|
848
|
+
}
|
|
849
|
+
const key = options.key ?? "q";
|
|
850
|
+
const router = useRouter();
|
|
851
|
+
const querystring = router.query[key];
|
|
852
|
+
const [queryParams, setQueryParams] = useState(
|
|
853
|
+
decode(ctx.alepha, schema, router.query[key])
|
|
854
|
+
);
|
|
855
|
+
useEffect(() => {
|
|
856
|
+
setQueryParams(decode(ctx.alepha, schema, querystring));
|
|
857
|
+
}, [querystring]);
|
|
858
|
+
return [
|
|
859
|
+
queryParams,
|
|
860
|
+
(queryParams2) => {
|
|
861
|
+
setQueryParams(queryParams2);
|
|
862
|
+
router.setQueryParams(
|
|
863
|
+
{ [key]: encode(ctx.alepha, schema, queryParams2) },
|
|
864
|
+
{
|
|
865
|
+
merge: true
|
|
866
|
+
}
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
];
|
|
870
|
+
};
|
|
871
|
+
const encode = (alepha, schema, data) => {
|
|
872
|
+
return btoa(JSON.stringify(alepha.parse(schema, data)));
|
|
873
|
+
};
|
|
874
|
+
const decode = (alepha, schema, data) => {
|
|
875
|
+
try {
|
|
876
|
+
return alepha.parse(schema, JSON.parse(atob(decodeURIComponent(data))));
|
|
877
|
+
} catch (error) {
|
|
878
|
+
return {};
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const useRouterEvents = (opts = {}) => {
|
|
883
|
+
const ctx = useContext(RouterContext);
|
|
884
|
+
const layer = useContext(RouterLayerContext);
|
|
885
|
+
if (!ctx || !layer) {
|
|
886
|
+
throw new Error("useRouter must be used within a RouterProvider");
|
|
887
|
+
}
|
|
888
|
+
useEffect(() => {
|
|
889
|
+
const subs = [];
|
|
890
|
+
const onBegin = opts.onBegin;
|
|
891
|
+
const onEnd = opts.onEnd;
|
|
892
|
+
const onError = opts.onError;
|
|
893
|
+
if (onBegin) {
|
|
894
|
+
subs.push(ctx.router.on("begin", onBegin));
|
|
895
|
+
}
|
|
896
|
+
if (onEnd) {
|
|
897
|
+
subs.push(ctx.router.on("end", onEnd));
|
|
898
|
+
}
|
|
899
|
+
if (onError) {
|
|
900
|
+
subs.push(ctx.router.on("error", onError));
|
|
901
|
+
}
|
|
902
|
+
return () => {
|
|
903
|
+
for (const sub of subs) {
|
|
904
|
+
sub();
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
}, []);
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const useRouterState = () => {
|
|
911
|
+
const ctx = useContext(RouterContext);
|
|
912
|
+
const layer = useContext(RouterLayerContext);
|
|
913
|
+
if (!ctx || !layer) {
|
|
914
|
+
throw new Error("useRouter must be used within a RouterProvider");
|
|
915
|
+
}
|
|
916
|
+
const [state, setState] = useState(ctx.state);
|
|
917
|
+
useEffect(
|
|
918
|
+
() => ctx.router.on("end", (it) => {
|
|
919
|
+
setState({ ...it });
|
|
920
|
+
}),
|
|
921
|
+
[]
|
|
922
|
+
);
|
|
923
|
+
return state;
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
export { $page as $, NestedView as N, PageDescriptorProvider as P, Router as R, RouterContext as a, RouterLayerContext as b, useClient as c, useInject as d, useQueryParams as e, RouterHookApi as f, useRouter as g, useRouterEvents as h, useRouterState as i, RedirectException as j, ReactBrowserProvider as k, pageDescriptorKey as p, useActive as u };
|