@hybridly/core 0.0.1-alpha.13 → 0.0.1-alpha.15
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.cjs +149 -7
- package/dist/index.d.ts +101 -69
- package/dist/index.mjs +150 -9
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -306,6 +306,139 @@ function createSerializer(options) {
|
|
|
306
306
|
};
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
class Route {
|
|
310
|
+
constructor(name, absolute) {
|
|
311
|
+
this.name = name;
|
|
312
|
+
this.absolute = absolute;
|
|
313
|
+
this.definition = Route.getDefinition(name);
|
|
314
|
+
}
|
|
315
|
+
static getDefinition(name) {
|
|
316
|
+
const context = getInternalRouterContext();
|
|
317
|
+
if (!context.routing) {
|
|
318
|
+
throw new Error("Routing is not initialized. Make sure the Vite plugin is enabled and that `virtual:hybridly/router` is imported and that `php artisan route:list` returns no error.");
|
|
319
|
+
}
|
|
320
|
+
const route = context.routing?.routes?.[name];
|
|
321
|
+
if (!route) {
|
|
322
|
+
throw new Error(`Route ${name.toString()} does not exist.`);
|
|
323
|
+
}
|
|
324
|
+
return route;
|
|
325
|
+
}
|
|
326
|
+
get template() {
|
|
327
|
+
const context = getInternalRouterContext();
|
|
328
|
+
const origin = !this.absolute ? "" : this.definition.domain ? `${context.routing?.url.match(/^\w+:\/\//)?.[0]}${this.definition.domain}${context.routing?.port ? `:${context.routing?.port}` : ""}` : context.routing?.url;
|
|
329
|
+
return `${origin}/${this.definition.uri}`.replace(/\/+$/, "");
|
|
330
|
+
}
|
|
331
|
+
get parameterSegments() {
|
|
332
|
+
return this.template.match(/{[^}?]+\??}/g)?.map((segment) => ({
|
|
333
|
+
name: segment.replace(/{|\??}/g, ""),
|
|
334
|
+
required: !/\?}$/.test(segment)
|
|
335
|
+
})) ?? [];
|
|
336
|
+
}
|
|
337
|
+
matchesUrl(url) {
|
|
338
|
+
if (!this.definition.method.includes("GET")) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
const pattern = this.template.replace(/(\/?){([^}?]*)(\??)}/g, (_, slash, segment, optional) => {
|
|
342
|
+
const regex = `(?<${segment}>${this.definition.wheres?.[segment]?.replace(/(^\^)|(\$$)/g, "") || "[^/?]+"})`;
|
|
343
|
+
return optional ? `(${slash}${regex})?` : `${slash}${regex}`;
|
|
344
|
+
}).replace(/^\w+:\/\//, "");
|
|
345
|
+
const [location, query] = url.replace(/^\w+:\/\//, "").split("?");
|
|
346
|
+
const matches = new RegExp(`^${pattern}/?$`).exec(location);
|
|
347
|
+
return matches ? { params: matches.groups, query: qs.parse(query) } : false;
|
|
348
|
+
}
|
|
349
|
+
compile(params) {
|
|
350
|
+
const segments = this.parameterSegments;
|
|
351
|
+
if (!segments.length) {
|
|
352
|
+
return this.template;
|
|
353
|
+
}
|
|
354
|
+
return this.template.replace(/{([^}?]+)(\??)}/g, (_, segment, optional) => {
|
|
355
|
+
if (!optional && [null, void 0].includes(params?.[segment])) {
|
|
356
|
+
throw new Error(`Router error: [${segment}] parameter is required for route [${this.name}].`);
|
|
357
|
+
}
|
|
358
|
+
if (segments[segments.length - 1].name === segment && this.definition?.wheres?.[segment] === ".*") {
|
|
359
|
+
return encodeURIComponent(params[segment] ?? "").replace(/%2F/g, "/");
|
|
360
|
+
}
|
|
361
|
+
if (this.definition?.wheres?.[segment] && !new RegExp(`^${optional ? `(${this.definition?.wheres?.[segment]})?` : this.definition?.wheres?.[segment]}$`).test(params[segment] ?? "")) {
|
|
362
|
+
throw new Error(`Router error: [${segment}] parameter does not match required format [${this.definition?.wheres?.[segment]}] for route [${this.name}].`);
|
|
363
|
+
}
|
|
364
|
+
return encodeURIComponent(params[segment] ?? "");
|
|
365
|
+
}).replace(/\/+$/, "");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
class Router extends String {
|
|
370
|
+
constructor(name, parameters, absolute = true) {
|
|
371
|
+
super();
|
|
372
|
+
const context = getInternalRouterContext();
|
|
373
|
+
this.route = new Route(name, absolute);
|
|
374
|
+
this.routing = context.routing;
|
|
375
|
+
this.setParameters(parameters);
|
|
376
|
+
}
|
|
377
|
+
toString() {
|
|
378
|
+
const unhandled = Object.keys(this.parameters).filter((key) => !this.route.parameterSegments.some(({ name }) => name === key)).filter((key) => key !== "_query").reduce((result, current) => ({ ...result, [current]: this.parameters[current] }), {});
|
|
379
|
+
return this.route.compile(this.parameters) + qs.stringify({ ...unhandled, ...this.parameters._query }, {
|
|
380
|
+
addQueryPrefix: true,
|
|
381
|
+
arrayFormat: "indices",
|
|
382
|
+
encodeValuesOnly: true,
|
|
383
|
+
skipNulls: true,
|
|
384
|
+
encoder: (value, encoder) => typeof value === "boolean" ? Number(value).toString() : encoder(value)
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
static has(name) {
|
|
388
|
+
try {
|
|
389
|
+
Route.getDefinition(name);
|
|
390
|
+
return true;
|
|
391
|
+
} catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
setParameters(parameters) {
|
|
396
|
+
this.parameters = parameters ?? {};
|
|
397
|
+
this.parameters = ["string", "number"].includes(typeof this.parameters) ? [this.parameters] : this.parameters;
|
|
398
|
+
const segments = this.route.parameterSegments.filter(({ name }) => !this.routing.defaults[name]);
|
|
399
|
+
if (Array.isArray(this.parameters)) {
|
|
400
|
+
this.parameters = this.parameters.reduce((result, current, i) => segments[i] ? { ...result, [segments[i].name]: current } : typeof current === "object" ? { ...result, ...current } : { ...result, [current]: "" }, {});
|
|
401
|
+
} else if (segments.length === 1 && !this.parameters[segments[0].name] && (Reflect.has(this.parameters, Object.values(this.route.definition.bindings)[0]) || Reflect.has(this.parameters, "id"))) {
|
|
402
|
+
this.parameters = { [segments[0].name]: this.parameters };
|
|
403
|
+
}
|
|
404
|
+
this.parameters = {
|
|
405
|
+
...this.getDefaults(),
|
|
406
|
+
...this.substituteBindings()
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
getDefaults() {
|
|
410
|
+
return this.route.parameterSegments.filter(({ name }) => this.routing.defaults[name]).reduce((result, { name }) => ({ ...result, [name]: this.routing.defaults[name] }), {});
|
|
411
|
+
}
|
|
412
|
+
substituteBindings() {
|
|
413
|
+
return Object.entries(this.parameters).reduce((result, [key, value]) => {
|
|
414
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !this.route.parameterSegments.some(({ name }) => name === key)) {
|
|
415
|
+
return { ...result, [key]: value };
|
|
416
|
+
}
|
|
417
|
+
if (!Reflect.has(value, this.route.definition.bindings[key])) {
|
|
418
|
+
if (Reflect.has(value, "id")) {
|
|
419
|
+
this.route.definition.bindings[key] = "id";
|
|
420
|
+
} else {
|
|
421
|
+
throw new Error(`Router error: object passed as [${key}] parameter is missing route model binding key [${this.route.definition.bindings?.[key]}].`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return { ...result, [key]: value[this.route.definition.bindings[key]] };
|
|
425
|
+
}, {});
|
|
426
|
+
}
|
|
427
|
+
valueOf() {
|
|
428
|
+
return this.toString();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function route(name, parameters, absolute) {
|
|
433
|
+
return new Router(name, parameters, absolute).toString();
|
|
434
|
+
}
|
|
435
|
+
function updateRoutingConfiguration(routing) {
|
|
436
|
+
if (!routing) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
setContext({ routing });
|
|
440
|
+
}
|
|
441
|
+
|
|
309
442
|
const state = {
|
|
310
443
|
initialized: false,
|
|
311
444
|
context: {}
|
|
@@ -325,10 +458,14 @@ async function initializeContext(options) {
|
|
|
325
458
|
...options.payload,
|
|
326
459
|
serializer: createSerializer(options),
|
|
327
460
|
url: makeUrl(options.payload.url).toString(),
|
|
328
|
-
adapter:
|
|
461
|
+
adapter: {
|
|
462
|
+
...options.adapter,
|
|
463
|
+
updateRoutingConfiguration
|
|
464
|
+
},
|
|
329
465
|
scrollRegions: [],
|
|
330
466
|
plugins: options.plugins ?? [],
|
|
331
467
|
axios: options.axios ?? axios__default.create(),
|
|
468
|
+
routing: options.routing,
|
|
332
469
|
hooks: {},
|
|
333
470
|
state: {}
|
|
334
471
|
};
|
|
@@ -409,6 +546,11 @@ const router = {
|
|
|
409
546
|
delete: async (url, options = {}) => await performHybridNavigation({ preserveState: true, ...options, url, method: "DELETE" }),
|
|
410
547
|
local: async (url, options) => await performLocalNavigation(url, options),
|
|
411
548
|
external: (url, data = {}) => navigateToExternalUrl(url, data),
|
|
549
|
+
to: async (name, parameters, options) => {
|
|
550
|
+
const url = new Router(name, parameters).toString();
|
|
551
|
+
const method = Route.getDefinition(name).method.at(0);
|
|
552
|
+
return await performHybridNavigation({ url, ...options, method });
|
|
553
|
+
},
|
|
412
554
|
history: {
|
|
413
555
|
get: (key) => getKeyFromHistory(key),
|
|
414
556
|
remember: (key, value) => remember(key, value)
|
|
@@ -423,6 +565,11 @@ async function performHybridNavigation(options) {
|
|
|
423
565
|
const context = getRouterContext();
|
|
424
566
|
utils.debug.router("Making a hybrid navigation:", { context, options, navigationId });
|
|
425
567
|
try {
|
|
568
|
+
if (!options.method) {
|
|
569
|
+
utils.debug.router("Setting method to GET because none was provided.");
|
|
570
|
+
options.method = "GET";
|
|
571
|
+
}
|
|
572
|
+
options.method = options.method.toUpperCase();
|
|
426
573
|
if ((utils.hasFiles(options.data) || options.useFormData) && !(options.data instanceof FormData)) {
|
|
427
574
|
options.data = utils.objectToFormData(options.data);
|
|
428
575
|
utils.debug.router("Converted data to FormData.", options.data);
|
|
@@ -432,10 +579,6 @@ async function performHybridNavigation(options) {
|
|
|
432
579
|
options.method = "POST";
|
|
433
580
|
}
|
|
434
581
|
}
|
|
435
|
-
if (!options.method) {
|
|
436
|
-
utils.debug.router("Setting method to GET because none was provided.");
|
|
437
|
-
options.method = "GET";
|
|
438
|
-
}
|
|
439
582
|
if (!(options.data instanceof FormData) && options.method === "GET" && Object.keys(options.data ?? {}).length) {
|
|
440
583
|
utils.debug.router("Transforming data to query parameters.", options.data);
|
|
441
584
|
options.url = makeUrl(options.url ?? context.url, {
|
|
@@ -551,12 +694,10 @@ async function performHybridNavigation(options) {
|
|
|
551
694
|
await utils.match(error.constructor.name, {
|
|
552
695
|
NavigationCancelledError: async () => {
|
|
553
696
|
utils.debug.router('The request was cancelled through the "before" hook.', error);
|
|
554
|
-
console.warn(error);
|
|
555
697
|
await runHooks("abort", options.hooks, context);
|
|
556
698
|
},
|
|
557
699
|
AbortError: async () => {
|
|
558
700
|
utils.debug.router("The request was cancelled.", error);
|
|
559
|
-
console.warn(error);
|
|
560
701
|
await runHooks("abort", options.hooks, context);
|
|
561
702
|
},
|
|
562
703
|
NotAHybridResponseError: async () => {
|
|
@@ -683,5 +824,6 @@ exports.definePlugin = definePlugin;
|
|
|
683
824
|
exports.getRouterContext = getRouterContext;
|
|
684
825
|
exports.makeUrl = makeUrl;
|
|
685
826
|
exports.registerHook = registerHook;
|
|
827
|
+
exports.route = route;
|
|
686
828
|
exports.router = router;
|
|
687
829
|
exports.sameUrls = sameUrls;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RequestData } from '@hybridly/utils';
|
|
2
|
-
import { AxiosResponse,
|
|
2
|
+
import { AxiosResponse, AxiosProgressEvent, Axios } from 'axios';
|
|
3
3
|
|
|
4
4
|
type MaybePromise<T> = T | Promise<T>;
|
|
5
5
|
|
|
@@ -72,72 +72,6 @@ interface Plugin extends Partial<Hooks> {
|
|
|
72
72
|
}
|
|
73
73
|
declare function definePlugin(plugin: Plugin): Plugin;
|
|
74
74
|
|
|
75
|
-
/** Options for creating a router context. */
|
|
76
|
-
interface RouterContextOptions {
|
|
77
|
-
/** The initial payload served by the browser. */
|
|
78
|
-
payload: HybridPayload;
|
|
79
|
-
/** Adapter-specific functions. */
|
|
80
|
-
adapter: Adapter;
|
|
81
|
-
/** History state serializer. */
|
|
82
|
-
serializer?: Serializer;
|
|
83
|
-
/** List of plugins. */
|
|
84
|
-
plugins?: Plugin[];
|
|
85
|
-
/** The Axios instance. */
|
|
86
|
-
axios?: Axios;
|
|
87
|
-
}
|
|
88
|
-
/** Router context. */
|
|
89
|
-
interface InternalRouterContext {
|
|
90
|
-
/** The current, normalized URL. */
|
|
91
|
-
url: string;
|
|
92
|
-
/** The current view. */
|
|
93
|
-
view: View;
|
|
94
|
-
/** The current, optional dialog. */
|
|
95
|
-
dialog?: View;
|
|
96
|
-
/** The current local asset version. */
|
|
97
|
-
version: string;
|
|
98
|
-
/** The current adapter's functions. */
|
|
99
|
-
adapter: Adapter;
|
|
100
|
-
/** Scroll positions of the current page's DOM elements. */
|
|
101
|
-
scrollRegions: ScrollRegion[];
|
|
102
|
-
/** Arbitrary state. */
|
|
103
|
-
state: Record<string, any>;
|
|
104
|
-
/** Currently pending navigation. */
|
|
105
|
-
pendingNavigation?: PendingNavigation;
|
|
106
|
-
/** History state serializer. */
|
|
107
|
-
serializer: Serializer;
|
|
108
|
-
/** List of plugins. */
|
|
109
|
-
plugins: Plugin[];
|
|
110
|
-
/** Global hooks. */
|
|
111
|
-
hooks: Partial<Record<keyof Hooks, Array<Function>>>;
|
|
112
|
-
/** The Axios instance. */
|
|
113
|
-
axios: Axios;
|
|
114
|
-
}
|
|
115
|
-
/** Router context. */
|
|
116
|
-
type RouterContext = Readonly<InternalRouterContext>;
|
|
117
|
-
/** Adapter-specific functions. */
|
|
118
|
-
interface Adapter {
|
|
119
|
-
/** Resolves a component from the given name. */
|
|
120
|
-
resolveComponent: ResolveComponent;
|
|
121
|
-
/** Swaps to the given view. */
|
|
122
|
-
swapView: SwapView;
|
|
123
|
-
/** Swaps to the given dialog. */
|
|
124
|
-
swapDialog: SwapDialog;
|
|
125
|
-
/** Called when the context is updated. */
|
|
126
|
-
update?: (context: InternalRouterContext) => void;
|
|
127
|
-
}
|
|
128
|
-
interface ScrollRegion {
|
|
129
|
-
top: number;
|
|
130
|
-
left: number;
|
|
131
|
-
}
|
|
132
|
-
/** Provides methods to serialize the state into the history state. */
|
|
133
|
-
interface Serializer {
|
|
134
|
-
serialize: <T>(view: T) => any;
|
|
135
|
-
unserialize: <T>(state: any) => T;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Gets the current context. */
|
|
139
|
-
declare function getRouterContext(): RouterContext;
|
|
140
|
-
|
|
141
75
|
type UrlResolvable = string | URL | Location;
|
|
142
76
|
type UrlTransformable = Partial<Omit<URL, 'searchParams' | 'toJSON' | 'toString'>> & {
|
|
143
77
|
query?: any;
|
|
@@ -207,7 +141,7 @@ interface HybridRequestOptions extends Omit<NavigationOptions, 'payload'> {
|
|
|
207
141
|
/** The URL to navigation. */
|
|
208
142
|
url?: UrlResolvable;
|
|
209
143
|
/** HTTP verb to use for the request. */
|
|
210
|
-
method?: Method
|
|
144
|
+
method?: Method | Lowercase<Method>;
|
|
211
145
|
/** Body of the request. */
|
|
212
146
|
data?: RequestData;
|
|
213
147
|
/** Which properties to update for this navigation. Other properties will be ignored. */
|
|
@@ -248,6 +182,8 @@ interface Router {
|
|
|
248
182
|
navigate: (options: HybridRequestOptions) => Promise<NavigationResponse>;
|
|
249
183
|
/** Reloads the current page. */
|
|
250
184
|
reload: (options?: HybridRequestOptions) => Promise<NavigationResponse>;
|
|
185
|
+
/** Makes a request to given named route. The HTTP verb is determined automatically but can be overriden. */
|
|
186
|
+
to: <T extends RouteName>(name: T, parameters?: RouteParameters<T>, options?: Omit<HybridRequestOptions, 'url'>) => Promise<NavigationResponse>;
|
|
251
187
|
/** Makes a GET request to the given URL. */
|
|
252
188
|
get: (url: UrlResolvable, options?: Omit<HybridRequestOptions, 'method' | 'url'>) => Promise<NavigationResponse>;
|
|
253
189
|
/** Makes a POST request to the given URL. */
|
|
@@ -324,6 +260,97 @@ interface Errors {
|
|
|
324
260
|
[key: string]: string;
|
|
325
261
|
}
|
|
326
262
|
|
|
263
|
+
interface RoutingConfiguration {
|
|
264
|
+
url: string;
|
|
265
|
+
port?: number;
|
|
266
|
+
defaults: Record<string, any>;
|
|
267
|
+
routes: Record<string, RouteDefinition>;
|
|
268
|
+
}
|
|
269
|
+
interface RouteDefinition {
|
|
270
|
+
uri: string;
|
|
271
|
+
method: Method[];
|
|
272
|
+
bindings: Record<string, string>;
|
|
273
|
+
domain?: string;
|
|
274
|
+
wheres?: Record<string, string>;
|
|
275
|
+
}
|
|
276
|
+
interface GlobalRouteCollection extends RoutingConfiguration {
|
|
277
|
+
}
|
|
278
|
+
type RouteName = keyof GlobalRouteCollection['routes'];
|
|
279
|
+
type RouteParameters<T extends RouteName> = Record<keyof GlobalRouteCollection['routes'][T]['bindings'], any> & Record<string, any>;
|
|
280
|
+
|
|
281
|
+
/** Options for creating a router context. */
|
|
282
|
+
interface RouterContextOptions {
|
|
283
|
+
/** The initial payload served by the browser. */
|
|
284
|
+
payload: HybridPayload;
|
|
285
|
+
/** Adapter-specific functions. */
|
|
286
|
+
adapter: Adapter;
|
|
287
|
+
/** History state serializer. */
|
|
288
|
+
serializer?: Serializer;
|
|
289
|
+
/** List of plugins. */
|
|
290
|
+
plugins?: Plugin[];
|
|
291
|
+
/** The Axios instance. */
|
|
292
|
+
axios?: Axios;
|
|
293
|
+
/** Initial routing configuration. */
|
|
294
|
+
routing?: RoutingConfiguration;
|
|
295
|
+
}
|
|
296
|
+
/** Router context. */
|
|
297
|
+
interface InternalRouterContext {
|
|
298
|
+
/** The current, normalized URL. */
|
|
299
|
+
url: string;
|
|
300
|
+
/** The current view. */
|
|
301
|
+
view: View;
|
|
302
|
+
/** The current, optional dialog. */
|
|
303
|
+
dialog?: View;
|
|
304
|
+
/** The current local asset version. */
|
|
305
|
+
version: string;
|
|
306
|
+
/** The current adapter's functions. */
|
|
307
|
+
adapter: ResolvedAdapter;
|
|
308
|
+
/** Scroll positions of the current page's DOM elements. */
|
|
309
|
+
scrollRegions: ScrollRegion[];
|
|
310
|
+
/** Arbitrary state. */
|
|
311
|
+
state: Record<string, any>;
|
|
312
|
+
/** Currently pending navigation. */
|
|
313
|
+
pendingNavigation?: PendingNavigation;
|
|
314
|
+
/** History state serializer. */
|
|
315
|
+
serializer: Serializer;
|
|
316
|
+
/** List of plugins. */
|
|
317
|
+
plugins: Plugin[];
|
|
318
|
+
/** Global hooks. */
|
|
319
|
+
hooks: Partial<Record<keyof Hooks, Array<Function>>>;
|
|
320
|
+
/** The Axios instance. */
|
|
321
|
+
axios: Axios;
|
|
322
|
+
/** Routing configuration. */
|
|
323
|
+
routing?: RoutingConfiguration;
|
|
324
|
+
}
|
|
325
|
+
/** Router context. */
|
|
326
|
+
type RouterContext = Readonly<InternalRouterContext>;
|
|
327
|
+
/** Adapter-specific functions. */
|
|
328
|
+
interface Adapter {
|
|
329
|
+
/** Resolves a component from the given name. */
|
|
330
|
+
resolveComponent: ResolveComponent;
|
|
331
|
+
/** Swaps to the given view. */
|
|
332
|
+
swapView: SwapView;
|
|
333
|
+
/** Swaps to the given dialog. */
|
|
334
|
+
swapDialog: SwapDialog;
|
|
335
|
+
/** Called when the context is updated. */
|
|
336
|
+
update?: (context: InternalRouterContext) => void;
|
|
337
|
+
}
|
|
338
|
+
interface ResolvedAdapter extends Adapter {
|
|
339
|
+
updateRoutingConfiguration: (routing?: RoutingConfiguration) => void;
|
|
340
|
+
}
|
|
341
|
+
interface ScrollRegion {
|
|
342
|
+
top: number;
|
|
343
|
+
left: number;
|
|
344
|
+
}
|
|
345
|
+
/** Provides methods to serialize the state into the history state. */
|
|
346
|
+
interface Serializer {
|
|
347
|
+
serialize: <T>(view: T) => any;
|
|
348
|
+
unserialize: <T>(state: any) => T;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Gets the current context. */
|
|
352
|
+
declare function getRouterContext(): RouterContext;
|
|
353
|
+
|
|
327
354
|
/**
|
|
328
355
|
* The hybridly router.
|
|
329
356
|
* This is the core function that you can use to navigate in
|
|
@@ -346,6 +373,11 @@ interface Authorizable<Authorizations extends Record<string, boolean>> {
|
|
|
346
373
|
*/
|
|
347
374
|
declare function can<Authorizations extends Record<string, boolean>, Data extends Authorizable<Authorizations>, Action extends keyof Data['authorization']>(resource: Data, action: Action): Authorizations[Action];
|
|
348
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Generates a route from the given route name.
|
|
378
|
+
*/
|
|
379
|
+
declare function route<T extends RouteName>(name: T, parameters?: RouteParameters<T>, absolute?: boolean): string;
|
|
380
|
+
|
|
349
381
|
declare const STORAGE_EXTERNAL_KEY = "hybridly:external";
|
|
350
382
|
declare const HYBRIDLY_HEADER = "x-hybrid";
|
|
351
383
|
declare const EXTERNAL_NAVIGATION_HEADER: string;
|
|
@@ -382,4 +414,4 @@ declare namespace constants {
|
|
|
382
414
|
};
|
|
383
415
|
}
|
|
384
416
|
|
|
385
|
-
export { Authorizable, HybridPayload, HybridRequestOptions, MaybePromise, Method, NavigationResponse, Plugin, Progress, ResolveComponent, Router, RouterContext, RouterContextOptions, UrlResolvable, can, constants, createRouter, definePlugin, getRouterContext, makeUrl, registerHook, router, sameUrls };
|
|
417
|
+
export { Authorizable, GlobalRouteCollection, HybridPayload, HybridRequestOptions, MaybePromise, Method, NavigationResponse, Plugin, Progress, ResolveComponent, RouteDefinition, RouteName, RouteParameters, Router, RouterContext, RouterContextOptions, RoutingConfiguration, UrlResolvable, can, constants, createRouter, definePlugin, getRouterContext, makeUrl, registerHook, route, router, sameUrls };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import qs from 'qs';
|
|
1
|
+
import qs, { parse, stringify } from 'qs';
|
|
2
2
|
import { debug, merge, debounce, random, hasFiles, objectToFormData, when, match, showResponseErrorModal } from '@hybridly/utils';
|
|
3
3
|
import axios from 'axios';
|
|
4
4
|
|
|
@@ -297,6 +297,139 @@ function createSerializer(options) {
|
|
|
297
297
|
};
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
class Route {
|
|
301
|
+
constructor(name, absolute) {
|
|
302
|
+
this.name = name;
|
|
303
|
+
this.absolute = absolute;
|
|
304
|
+
this.definition = Route.getDefinition(name);
|
|
305
|
+
}
|
|
306
|
+
static getDefinition(name) {
|
|
307
|
+
const context = getInternalRouterContext();
|
|
308
|
+
if (!context.routing) {
|
|
309
|
+
throw new Error("Routing is not initialized. Make sure the Vite plugin is enabled and that `virtual:hybridly/router` is imported and that `php artisan route:list` returns no error.");
|
|
310
|
+
}
|
|
311
|
+
const route = context.routing?.routes?.[name];
|
|
312
|
+
if (!route) {
|
|
313
|
+
throw new Error(`Route ${name.toString()} does not exist.`);
|
|
314
|
+
}
|
|
315
|
+
return route;
|
|
316
|
+
}
|
|
317
|
+
get template() {
|
|
318
|
+
const context = getInternalRouterContext();
|
|
319
|
+
const origin = !this.absolute ? "" : this.definition.domain ? `${context.routing?.url.match(/^\w+:\/\//)?.[0]}${this.definition.domain}${context.routing?.port ? `:${context.routing?.port}` : ""}` : context.routing?.url;
|
|
320
|
+
return `${origin}/${this.definition.uri}`.replace(/\/+$/, "");
|
|
321
|
+
}
|
|
322
|
+
get parameterSegments() {
|
|
323
|
+
return this.template.match(/{[^}?]+\??}/g)?.map((segment) => ({
|
|
324
|
+
name: segment.replace(/{|\??}/g, ""),
|
|
325
|
+
required: !/\?}$/.test(segment)
|
|
326
|
+
})) ?? [];
|
|
327
|
+
}
|
|
328
|
+
matchesUrl(url) {
|
|
329
|
+
if (!this.definition.method.includes("GET")) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
const pattern = this.template.replace(/(\/?){([^}?]*)(\??)}/g, (_, slash, segment, optional) => {
|
|
333
|
+
const regex = `(?<${segment}>${this.definition.wheres?.[segment]?.replace(/(^\^)|(\$$)/g, "") || "[^/?]+"})`;
|
|
334
|
+
return optional ? `(${slash}${regex})?` : `${slash}${regex}`;
|
|
335
|
+
}).replace(/^\w+:\/\//, "");
|
|
336
|
+
const [location, query] = url.replace(/^\w+:\/\//, "").split("?");
|
|
337
|
+
const matches = new RegExp(`^${pattern}/?$`).exec(location);
|
|
338
|
+
return matches ? { params: matches.groups, query: parse(query) } : false;
|
|
339
|
+
}
|
|
340
|
+
compile(params) {
|
|
341
|
+
const segments = this.parameterSegments;
|
|
342
|
+
if (!segments.length) {
|
|
343
|
+
return this.template;
|
|
344
|
+
}
|
|
345
|
+
return this.template.replace(/{([^}?]+)(\??)}/g, (_, segment, optional) => {
|
|
346
|
+
if (!optional && [null, void 0].includes(params?.[segment])) {
|
|
347
|
+
throw new Error(`Router error: [${segment}] parameter is required for route [${this.name}].`);
|
|
348
|
+
}
|
|
349
|
+
if (segments[segments.length - 1].name === segment && this.definition?.wheres?.[segment] === ".*") {
|
|
350
|
+
return encodeURIComponent(params[segment] ?? "").replace(/%2F/g, "/");
|
|
351
|
+
}
|
|
352
|
+
if (this.definition?.wheres?.[segment] && !new RegExp(`^${optional ? `(${this.definition?.wheres?.[segment]})?` : this.definition?.wheres?.[segment]}$`).test(params[segment] ?? "")) {
|
|
353
|
+
throw new Error(`Router error: [${segment}] parameter does not match required format [${this.definition?.wheres?.[segment]}] for route [${this.name}].`);
|
|
354
|
+
}
|
|
355
|
+
return encodeURIComponent(params[segment] ?? "");
|
|
356
|
+
}).replace(/\/+$/, "");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
class Router extends String {
|
|
361
|
+
constructor(name, parameters, absolute = true) {
|
|
362
|
+
super();
|
|
363
|
+
const context = getInternalRouterContext();
|
|
364
|
+
this.route = new Route(name, absolute);
|
|
365
|
+
this.routing = context.routing;
|
|
366
|
+
this.setParameters(parameters);
|
|
367
|
+
}
|
|
368
|
+
toString() {
|
|
369
|
+
const unhandled = Object.keys(this.parameters).filter((key) => !this.route.parameterSegments.some(({ name }) => name === key)).filter((key) => key !== "_query").reduce((result, current) => ({ ...result, [current]: this.parameters[current] }), {});
|
|
370
|
+
return this.route.compile(this.parameters) + stringify({ ...unhandled, ...this.parameters._query }, {
|
|
371
|
+
addQueryPrefix: true,
|
|
372
|
+
arrayFormat: "indices",
|
|
373
|
+
encodeValuesOnly: true,
|
|
374
|
+
skipNulls: true,
|
|
375
|
+
encoder: (value, encoder) => typeof value === "boolean" ? Number(value).toString() : encoder(value)
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
static has(name) {
|
|
379
|
+
try {
|
|
380
|
+
Route.getDefinition(name);
|
|
381
|
+
return true;
|
|
382
|
+
} catch {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
setParameters(parameters) {
|
|
387
|
+
this.parameters = parameters ?? {};
|
|
388
|
+
this.parameters = ["string", "number"].includes(typeof this.parameters) ? [this.parameters] : this.parameters;
|
|
389
|
+
const segments = this.route.parameterSegments.filter(({ name }) => !this.routing.defaults[name]);
|
|
390
|
+
if (Array.isArray(this.parameters)) {
|
|
391
|
+
this.parameters = this.parameters.reduce((result, current, i) => segments[i] ? { ...result, [segments[i].name]: current } : typeof current === "object" ? { ...result, ...current } : { ...result, [current]: "" }, {});
|
|
392
|
+
} else if (segments.length === 1 && !this.parameters[segments[0].name] && (Reflect.has(this.parameters, Object.values(this.route.definition.bindings)[0]) || Reflect.has(this.parameters, "id"))) {
|
|
393
|
+
this.parameters = { [segments[0].name]: this.parameters };
|
|
394
|
+
}
|
|
395
|
+
this.parameters = {
|
|
396
|
+
...this.getDefaults(),
|
|
397
|
+
...this.substituteBindings()
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
getDefaults() {
|
|
401
|
+
return this.route.parameterSegments.filter(({ name }) => this.routing.defaults[name]).reduce((result, { name }) => ({ ...result, [name]: this.routing.defaults[name] }), {});
|
|
402
|
+
}
|
|
403
|
+
substituteBindings() {
|
|
404
|
+
return Object.entries(this.parameters).reduce((result, [key, value]) => {
|
|
405
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !this.route.parameterSegments.some(({ name }) => name === key)) {
|
|
406
|
+
return { ...result, [key]: value };
|
|
407
|
+
}
|
|
408
|
+
if (!Reflect.has(value, this.route.definition.bindings[key])) {
|
|
409
|
+
if (Reflect.has(value, "id")) {
|
|
410
|
+
this.route.definition.bindings[key] = "id";
|
|
411
|
+
} else {
|
|
412
|
+
throw new Error(`Router error: object passed as [${key}] parameter is missing route model binding key [${this.route.definition.bindings?.[key]}].`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { ...result, [key]: value[this.route.definition.bindings[key]] };
|
|
416
|
+
}, {});
|
|
417
|
+
}
|
|
418
|
+
valueOf() {
|
|
419
|
+
return this.toString();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function route(name, parameters, absolute) {
|
|
424
|
+
return new Router(name, parameters, absolute).toString();
|
|
425
|
+
}
|
|
426
|
+
function updateRoutingConfiguration(routing) {
|
|
427
|
+
if (!routing) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
setContext({ routing });
|
|
431
|
+
}
|
|
432
|
+
|
|
300
433
|
const state = {
|
|
301
434
|
initialized: false,
|
|
302
435
|
context: {}
|
|
@@ -316,10 +449,14 @@ async function initializeContext(options) {
|
|
|
316
449
|
...options.payload,
|
|
317
450
|
serializer: createSerializer(options),
|
|
318
451
|
url: makeUrl(options.payload.url).toString(),
|
|
319
|
-
adapter:
|
|
452
|
+
adapter: {
|
|
453
|
+
...options.adapter,
|
|
454
|
+
updateRoutingConfiguration
|
|
455
|
+
},
|
|
320
456
|
scrollRegions: [],
|
|
321
457
|
plugins: options.plugins ?? [],
|
|
322
458
|
axios: options.axios ?? axios.create(),
|
|
459
|
+
routing: options.routing,
|
|
323
460
|
hooks: {},
|
|
324
461
|
state: {}
|
|
325
462
|
};
|
|
@@ -400,6 +537,11 @@ const router = {
|
|
|
400
537
|
delete: async (url, options = {}) => await performHybridNavigation({ preserveState: true, ...options, url, method: "DELETE" }),
|
|
401
538
|
local: async (url, options) => await performLocalNavigation(url, options),
|
|
402
539
|
external: (url, data = {}) => navigateToExternalUrl(url, data),
|
|
540
|
+
to: async (name, parameters, options) => {
|
|
541
|
+
const url = new Router(name, parameters).toString();
|
|
542
|
+
const method = Route.getDefinition(name).method.at(0);
|
|
543
|
+
return await performHybridNavigation({ url, ...options, method });
|
|
544
|
+
},
|
|
403
545
|
history: {
|
|
404
546
|
get: (key) => getKeyFromHistory(key),
|
|
405
547
|
remember: (key, value) => remember(key, value)
|
|
@@ -414,6 +556,11 @@ async function performHybridNavigation(options) {
|
|
|
414
556
|
const context = getRouterContext();
|
|
415
557
|
debug.router("Making a hybrid navigation:", { context, options, navigationId });
|
|
416
558
|
try {
|
|
559
|
+
if (!options.method) {
|
|
560
|
+
debug.router("Setting method to GET because none was provided.");
|
|
561
|
+
options.method = "GET";
|
|
562
|
+
}
|
|
563
|
+
options.method = options.method.toUpperCase();
|
|
417
564
|
if ((hasFiles(options.data) || options.useFormData) && !(options.data instanceof FormData)) {
|
|
418
565
|
options.data = objectToFormData(options.data);
|
|
419
566
|
debug.router("Converted data to FormData.", options.data);
|
|
@@ -423,10 +570,6 @@ async function performHybridNavigation(options) {
|
|
|
423
570
|
options.method = "POST";
|
|
424
571
|
}
|
|
425
572
|
}
|
|
426
|
-
if (!options.method) {
|
|
427
|
-
debug.router("Setting method to GET because none was provided.");
|
|
428
|
-
options.method = "GET";
|
|
429
|
-
}
|
|
430
573
|
if (!(options.data instanceof FormData) && options.method === "GET" && Object.keys(options.data ?? {}).length) {
|
|
431
574
|
debug.router("Transforming data to query parameters.", options.data);
|
|
432
575
|
options.url = makeUrl(options.url ?? context.url, {
|
|
@@ -542,12 +685,10 @@ async function performHybridNavigation(options) {
|
|
|
542
685
|
await match(error.constructor.name, {
|
|
543
686
|
NavigationCancelledError: async () => {
|
|
544
687
|
debug.router('The request was cancelled through the "before" hook.', error);
|
|
545
|
-
console.warn(error);
|
|
546
688
|
await runHooks("abort", options.hooks, context);
|
|
547
689
|
},
|
|
548
690
|
AbortError: async () => {
|
|
549
691
|
debug.router("The request was cancelled.", error);
|
|
550
|
-
console.warn(error);
|
|
551
692
|
await runHooks("abort", options.hooks, context);
|
|
552
693
|
},
|
|
553
694
|
NotAHybridResponseError: async () => {
|
|
@@ -667,4 +808,4 @@ function can(resource, action) {
|
|
|
667
808
|
return resource.authorization?.[action] ?? false;
|
|
668
809
|
}
|
|
669
810
|
|
|
670
|
-
export { can, constants, createRouter, definePlugin, getRouterContext, makeUrl, registerHook, router, sameUrls };
|
|
811
|
+
export { can, constants, createRouter, definePlugin, getRouterContext, makeUrl, registerHook, route, router, sameUrls };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hybridly/core",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.15",
|
|
4
4
|
"description": "A solution to develop server-driven, client-rendered applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"hybridly",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"axios": "^1"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"
|
|
40
|
-
"
|
|
39
|
+
"qs": "^6.11.0",
|
|
40
|
+
"@hybridly/utils": "0.0.1-alpha.15"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"defu": "^6.1.1"
|