@hybridly/core 0.4.0 → 0.4.2

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 CHANGED
@@ -160,7 +160,7 @@ async function restoreScrollPositions() {
160
160
  utils.debug.scroll("No region found to restore.");
161
161
  return;
162
162
  }
163
- context.adapter.onWaitingForMount(() => {
163
+ context.adapter.executeOnMounted(() => {
164
164
  utils.debug.scroll(`Restoring ${regions.length}/${context.scrollRegions.length} region(s).`);
165
165
  regions.forEach((el, i) => el.scrollTo({
166
166
  top: context.scrollRegions.at(i)?.top ?? el.scrollTop,
@@ -360,10 +360,59 @@ function createSerializer(options) {
360
360
  };
361
361
  }
362
362
 
363
+ function getUrlRegexForRoute(name) {
364
+ const routing = getRouting();
365
+ const definition = getRouteDefinition(name);
366
+ const path = definition.uri.replaceAll("/", "\\/");
367
+ const domain = definition.domain;
368
+ const protocolPrefix = routing.url.match(/^\w+:\/\//)?.[0];
369
+ const origin = domain ? `${protocolPrefix}${domain}${routing.port ? `:${routing.port}` : ""}`.replaceAll("/", "\\/") : routing.url.replaceAll("/", "\\/");
370
+ const urlPathRegexPattern = path.length > 0 ? `\\/${path.replace(/\/$/g, "")}` : "";
371
+ let urlRegexPattern = `^${origin.replaceAll(".", "\\.")}${urlPathRegexPattern}\\/?(\\?.*)?$`;
372
+ urlRegexPattern = urlRegexPattern.replace(/(\\\/?){([^}?]+)(\??)}/g, (_, slash, parameterName, optional) => {
373
+ const where = definition.wheres?.[parameterName];
374
+ let regexTemplate = where?.replace(/(^\^)|(\$$)/g, "") || "[^/?]+";
375
+ regexTemplate = `(?<${parameterName}>${regexTemplate})`;
376
+ if (optional) {
377
+ return `(${slash ? "\\/?" : ""}${regexTemplate})?`;
378
+ }
379
+ return (slash ? "\\/" : "") + regexTemplate;
380
+ });
381
+ return RegExp(urlRegexPattern);
382
+ }
383
+ function urlMatchesRoute(url, name, routeParameters) {
384
+ const parameters = routeParameters || {};
385
+ const definition = getRouting().routes[name];
386
+ if (!definition) {
387
+ return false;
388
+ }
389
+ const matches = getUrlRegexForRoute(name).exec(url);
390
+ if (!matches) {
391
+ return false;
392
+ }
393
+ for (const k in matches.groups) {
394
+ matches.groups[k] = typeof matches.groups[k] === "string" ? decodeURIComponent(matches.groups[k]) : matches.groups[k];
395
+ }
396
+ return Object.keys(parameters).every((parameterName) => {
397
+ let value = parameters[parameterName];
398
+ const bindingProperty = definition.bindings?.[parameterName];
399
+ if (bindingProperty && typeof value === "object") {
400
+ value = value[bindingProperty];
401
+ }
402
+ return matches.groups?.[parameterName] === value.toString();
403
+ });
404
+ }
363
405
  function generateRouteFromName(name, parameters, absolute, shouldThrow) {
364
406
  const url = getUrlFromName(name, parameters, shouldThrow);
365
407
  return absolute === false ? url.toString().replace(url.origin, "") : url.toString();
366
408
  }
409
+ function getNameFromUrl(url, parameters) {
410
+ const routing = getRouting();
411
+ const routes = Object.values(routing.routes).map((x) => x.name);
412
+ return routes.find((routeName) => {
413
+ return urlMatchesRoute(url, routeName, parameters);
414
+ });
415
+ }
367
416
  function getUrlFromName(name, parameters, shouldThrow) {
368
417
  const routing = getRouting();
369
418
  const definition = getRouteDefinition(name);
@@ -376,32 +425,39 @@ function getUrlFromName(name, parameters, shouldThrow) {
376
425
  }));
377
426
  return url;
378
427
  }
379
- function getRouteTransformable(routeName, routeParameters, shouldThrow) {
428
+ function getRouteParameterValue(routeName, parameterName, routeParameters) {
380
429
  const routing = getRouting();
430
+ const definition = getRouteDefinition(routeName);
431
+ const parameters = routeParameters || {};
432
+ const value = (() => {
433
+ const value2 = parameters[parameterName];
434
+ const bindingProperty = definition.bindings?.[parameterName];
435
+ if (bindingProperty && value2 != null && typeof value2 === "object") {
436
+ return value2[bindingProperty];
437
+ }
438
+ return value2;
439
+ })();
440
+ if (value) {
441
+ const where = definition.wheres?.[parameterName];
442
+ if (where && !new RegExp(where).test(value)) {
443
+ console.warn(`[hybridly:routing] Parameter [${parameterName}] does not match the required format [${where}] for route [${routeName}].`);
444
+ }
445
+ return value;
446
+ }
447
+ if (routing.defaults?.[parameterName]) {
448
+ return routing.defaults?.[parameterName];
449
+ }
450
+ }
451
+ function getRouteTransformable(routeName, routeParameters, shouldThrow) {
381
452
  const definition = getRouteDefinition(routeName);
382
453
  const parameters = routeParameters || {};
383
454
  const missing = Object.keys(parameters);
384
- const replaceParameter = (match, parameterName) => {
385
- const optional = /\?}$/.test(match);
386
- const value = (() => {
387
- const value2 = parameters[parameterName];
388
- const bindingProperty = definition.bindings?.[parameterName];
389
- if (bindingProperty && typeof value2 === "object") {
390
- return value2[bindingProperty];
391
- }
392
- return value2;
393
- })();
455
+ const replaceParameter = (match, parameterName, optional) => {
456
+ const value = getRouteParameterValue(routeName, parameterName, parameters);
394
457
  missing.splice(missing.indexOf(parameterName), 1);
395
458
  if (value) {
396
- const where = definition.wheres?.[parameterName];
397
- if (where && !new RegExp(where).test(value)) {
398
- console.warn(`[hybridly:routing] Parameter [${parameterName}] does not match the required format [${where}] for route [${routeName}].`);
399
- }
400
459
  return value;
401
460
  }
402
- if (routing.defaults?.[parameterName]) {
403
- return routing.defaults?.[parameterName];
404
- }
405
461
  if (optional) {
406
462
  return "";
407
463
  }
@@ -410,8 +466,8 @@ function getRouteTransformable(routeName, routeParameters, shouldThrow) {
410
466
  }
411
467
  throw new MissingRouteParameter(parameterName, routeName);
412
468
  };
413
- const path = definition.uri.replace(/{([^}?]+)\??}/g, replaceParameter);
414
- const domain = definition.domain?.replace(/{([^}?]+)\??}/g, replaceParameter);
469
+ const path = definition.uri.replace(/{([^}?]+)(\??)}/g, replaceParameter);
470
+ const domain = definition.domain?.replace(/{([^}?]+)(\??)}/g, replaceParameter);
415
471
  const remaining = Object.keys(parameters).filter((key) => missing.includes(key)).reduce((obj, key) => ({
416
472
  ...obj,
417
473
  [key]: parameters[key]
@@ -441,30 +497,28 @@ function getRouting() {
441
497
  }
442
498
  return routing;
443
499
  }
444
-
445
- function isCurrentFromName(name, parameters, mode = "loose") {
446
- const location = window.location;
447
- const matchee = (() => {
448
- try {
449
- return makeUrl(generateRouteFromName(name, parameters, true, false));
450
- } catch (error) {
451
- }
452
- })();
453
- if (!matchee) {
454
- return false;
455
- }
456
- if (mode === "strict") {
457
- return location.href === matchee.href;
458
- }
459
- return location.href.startsWith(matchee.href);
460
- }
461
-
462
500
  function route(name, parameters, absolute) {
463
501
  return generateRouteFromName(name, parameters, absolute);
464
502
  }
465
- function current(name, parameters, mode = "loose") {
466
- return isCurrentFromName(name, parameters, mode);
503
+
504
+ function getCurrentUrl() {
505
+ if (typeof window === "undefined") {
506
+ return getInternalRouterContext().url;
507
+ }
508
+ return window.location.toString();
509
+ }
510
+ function currentRouteMatches(name, parameters) {
511
+ const namePattern = name.replaceAll(".", "\\.").replaceAll("*", ".*");
512
+ const possibleRoutes = Object.values(getRouting().routes).filter((x) => x.method.includes("GET") && RegExp(namePattern).test(x.name)).map((x) => x.name);
513
+ const currentUrl = getCurrentUrl();
514
+ return possibleRoutes.some((routeName) => {
515
+ return urlMatchesRoute(currentUrl, routeName, parameters);
516
+ });
517
+ }
518
+ function getCurrentRouteName() {
519
+ return getNameFromUrl(getCurrentUrl());
467
520
  }
521
+
468
522
  function updateRoutingConfiguration(routing) {
469
523
  if (!routing) {
470
524
  return;
@@ -500,6 +554,7 @@ async function initializeContext(options) {
500
554
  plugins: options.plugins ?? [],
501
555
  axios: options.axios ?? axios__default.create(),
502
556
  routing: options.routing,
557
+ preloadCache: /* @__PURE__ */ new Map(),
503
558
  hooks: {},
504
559
  memo: {}
505
560
  };
@@ -590,6 +645,52 @@ async function closeDialog(options) {
590
645
  });
591
646
  }
592
647
 
648
+ function isPreloaded(targetUrl) {
649
+ const context = getInternalRouterContext();
650
+ return context.preloadCache.has(targetUrl.toString()) ?? false;
651
+ }
652
+ function getPreloadedRequest(targetUrl) {
653
+ const context = getInternalRouterContext();
654
+ return context.preloadCache.get(targetUrl.toString());
655
+ }
656
+ function storePreloadRequest(targetUrl, response) {
657
+ const context = getInternalRouterContext();
658
+ context.preloadCache.set(targetUrl.toString(), response);
659
+ }
660
+ function discardPreloadedRequest(targetUrl) {
661
+ const context = getInternalRouterContext();
662
+ return context.preloadCache.delete(targetUrl.toString());
663
+ }
664
+ async function performPreloadRequest(options) {
665
+ const context = getRouterContext();
666
+ const url = makeUrl(options.url ?? context.url);
667
+ if (isPreloaded(url)) {
668
+ utils.debug.router("This request is already preloaded.");
669
+ return false;
670
+ }
671
+ if (context.pendingNavigation) {
672
+ utils.debug.router("A navigation is pending, preload aborted.");
673
+ return false;
674
+ }
675
+ if (options.method !== "GET") {
676
+ utils.debug.router("Cannot preload non-GET requests.");
677
+ return false;
678
+ }
679
+ utils.debug.router(`Preloading response for [${url.toString()}]`);
680
+ try {
681
+ const response = await performHybridRequest(url, options);
682
+ if (!isHybridResponse(response)) {
683
+ utils.debug.router("Preload result was invalid.");
684
+ return false;
685
+ }
686
+ storePreloadRequest(url, response);
687
+ return true;
688
+ } catch (error) {
689
+ utils.debug.router("Preloading failed.");
690
+ return false;
691
+ }
692
+ }
693
+
593
694
  const router = {
594
695
  abort: async () => getRouterContext().pendingNavigation?.controller.abort(),
595
696
  active: () => !!getRouterContext().pendingNavigation,
@@ -601,12 +702,15 @@ const router = {
601
702
  patch: async (url, options = {}) => await performHybridNavigation({ preserveState: true, ...options, url, method: "PATCH" }),
602
703
  delete: async (url, options = {}) => await performHybridNavigation({ preserveState: true, ...options, url, method: "DELETE" }),
603
704
  local: async (url, options = {}) => await performLocalNavigation(url, options),
705
+ preload: async (url, options = {}) => await performPreloadRequest({ ...options, url, method: "GET" }),
604
706
  external: (url, data = {}) => navigateToExternalUrl(url, data),
605
707
  to: async (name, parameters, options) => {
606
708
  const url = generateRouteFromName(name, parameters);
607
709
  const method = getRouteDefinition(name).method.at(0);
608
710
  return await performHybridNavigation({ url, ...options, method });
609
711
  },
712
+ matches: (name, parameters) => currentRouteMatches(name, parameters),
713
+ current: () => getCurrentRouteName(),
610
714
  dialog: {
611
715
  close: (options) => closeDialog(options)
612
716
  },
@@ -632,11 +736,6 @@ async function performHybridNavigation(options) {
632
736
  if ((utils.hasFiles(options.data) || options.useFormData) && !(options.data instanceof FormData)) {
633
737
  options.data = utils.objectToFormData(options.data);
634
738
  utils.debug.router("Converted data to FormData.", options.data);
635
- if (options.method && ["PUT", "PATCH", "DELETE"].includes(options.method) && options.spoof !== false) {
636
- utils.debug.router(`Automatically spoofing method ${options.method}.`);
637
- options.data.append("_method", options.method);
638
- options.method = "POST";
639
- }
640
739
  }
641
740
  if (!(options.data instanceof FormData) && options.method === "GET" && Object.keys(options.data ?? {}).length) {
642
741
  utils.debug.router("Transforming data to query parameters.", options.data);
@@ -645,6 +744,19 @@ async function performHybridNavigation(options) {
645
744
  });
646
745
  options.data = {};
647
746
  }
747
+ if (["PUT", "PATCH", "DELETE"].includes(options.method) && options.spoof !== false) {
748
+ utils.debug.router(`Automatically spoofing method ${options.method}.`);
749
+ if (options.data instanceof FormData) {
750
+ options.data.append("_method", options.method);
751
+ } else if (Object.keys(options.data ?? {}).length) {
752
+ Object.assign(options.data, { _method: options.method });
753
+ } else if (typeof options.data === "undefined") {
754
+ options.data = { _method: options.method };
755
+ } else {
756
+ utils.debug.router("Could not spoof method because body type is not supported.", options.data);
757
+ }
758
+ options.method = "POST";
759
+ }
648
760
  if (!await runHooks("before", options.hooks, options, context)) {
649
761
  utils.debug.router('"before" event returned false, aborting the navigation.');
650
762
  throw new NavigationCancelledError('The navigation was cancelled by the "before" event.');
@@ -670,35 +782,7 @@ async function performHybridNavigation(options) {
670
782
  });
671
783
  await runHooks("start", options.hooks, context);
672
784
  utils.debug.router("Making request with axios.");
673
- const response = await context.axios.request({
674
- url: targetUrl.toString(),
675
- method: options.method,
676
- data: options.method === "GET" ? {} : options.data,
677
- params: options.method === "GET" ? options.data : {},
678
- signal: abortController.signal,
679
- headers: {
680
- ...options.headers,
681
- ...context.dialog ? { [DIALOG_KEY_HEADER]: context.dialog.key } : {},
682
- ...context.dialog ? { [DIALOG_REDIRECT_HEADER]: context.dialog.redirectUrl ?? "" } : {},
683
- ...utils.when(options.only !== void 0 || options.except !== void 0, {
684
- [PARTIAL_COMPONENT_HEADER]: context.view.component,
685
- ...utils.when(options.only, { [ONLY_DATA_HEADER]: JSON.stringify(options.only) }, {}),
686
- ...utils.when(options.except, { [EXCEPT_DATA_HEADER]: JSON.stringify(options.except) }, {})
687
- }, {}),
688
- ...utils.when(options.errorBag, { [ERROR_BAG_HEADER]: options.errorBag }, {}),
689
- ...utils.when(context.version, { [VERSION_HEADER]: context.version }, {}),
690
- [HYBRIDLY_HEADER]: true,
691
- "X-Requested-With": "XMLHttpRequest",
692
- "Accept": "text/html, application/xhtml+xml"
693
- },
694
- validateStatus: () => true,
695
- onUploadProgress: async (event) => {
696
- await runHooks("progress", options.hooks, {
697
- event,
698
- percentage: Math.round(event.loaded / (event.total ?? 0) * 100)
699
- }, context);
700
- }
701
- });
785
+ const response = await performHybridRequest(targetUrl, options, abortController);
702
786
  await runHooks("data", options.hooks, response, context);
703
787
  if (isExternalResponse(response)) {
704
788
  utils.debug.router("The response is explicitely external.");
@@ -842,6 +926,47 @@ async function navigate(options) {
842
926
  resetScrollPositions();
843
927
  }
844
928
  await runHooks("navigated", {}, options, context);
929
+ context.adapter.executeOnMounted(() => {
930
+ runHooks("mounted", {}, context);
931
+ });
932
+ }
933
+ async function performHybridRequest(targetUrl, options, abortController) {
934
+ const context = getInternalRouterContext();
935
+ const preloaded = getPreloadedRequest(targetUrl);
936
+ if (preloaded) {
937
+ utils.debug.router(`Found a pre-loaded request for [${targetUrl}]`);
938
+ discardPreloadedRequest(targetUrl);
939
+ return preloaded;
940
+ }
941
+ return await context.axios.request({
942
+ url: targetUrl.toString(),
943
+ method: options.method,
944
+ data: options.method === "GET" ? {} : options.data,
945
+ params: options.method === "GET" ? options.data : {},
946
+ signal: abortController?.signal,
947
+ headers: {
948
+ ...options.headers,
949
+ ...context.dialog ? { [DIALOG_KEY_HEADER]: context.dialog.key } : {},
950
+ ...context.dialog ? { [DIALOG_REDIRECT_HEADER]: context.dialog.redirectUrl ?? "" } : {},
951
+ ...utils.when(options.only !== void 0 || options.except !== void 0, {
952
+ [PARTIAL_COMPONENT_HEADER]: context.view.component,
953
+ ...utils.when(options.only, { [ONLY_DATA_HEADER]: JSON.stringify(options.only) }, {}),
954
+ ...utils.when(options.except, { [EXCEPT_DATA_HEADER]: JSON.stringify(options.except) }, {})
955
+ }, {}),
956
+ ...utils.when(options.errorBag, { [ERROR_BAG_HEADER]: options.errorBag }, {}),
957
+ ...utils.when(context.version, { [VERSION_HEADER]: context.version }, {}),
958
+ [HYBRIDLY_HEADER]: true,
959
+ "X-Requested-With": "XMLHttpRequest",
960
+ "Accept": "text/html, application/xhtml+xml"
961
+ },
962
+ validateStatus: () => true,
963
+ onUploadProgress: async (event) => {
964
+ await runHooks("progress", options.hooks, {
965
+ event,
966
+ percentage: Math.round(event.loaded / (event.total ?? 0) * 100)
967
+ }, context);
968
+ }
969
+ });
845
970
  }
846
971
  async function initializeRouter() {
847
972
  const context = getRouterContext();
@@ -887,7 +1012,6 @@ function can(resource, action) {
887
1012
  exports.can = can;
888
1013
  exports.constants = constants;
889
1014
  exports.createRouter = createRouter;
890
- exports.current = current;
891
1015
  exports.definePlugin = definePlugin;
892
1016
  exports.getRouterContext = getRouterContext;
893
1017
  exports.makeUrl = makeUrl;
package/dist/index.d.ts CHANGED
@@ -70,6 +70,10 @@ interface Hooks extends RequestHooks {
70
70
  * Called when a component has been navigated to.
71
71
  */
72
72
  navigated: (options: NavigationOptions, context: InternalRouterContext) => MaybePromise<any>;
73
+ /**
74
+ * Called when a component has been navigated to and was mounted by the adapter.
75
+ */
76
+ mounted: (context: InternalRouterContext) => MaybePromise<any>;
73
77
  }
74
78
  interface HookOptions {
75
79
  /** Executes the hook only once. */
@@ -228,6 +232,12 @@ interface Router {
228
232
  external: (url: UrlResolvable, data?: HybridRequestOptions['data']) => void;
229
233
  /** Navigates to the given URL without a server round-trip. */
230
234
  local: (url: UrlResolvable, options: ComponentNavigationOptions) => Promise<void>;
235
+ /** Preloads the given URL. The next time this URL is navigated to, it will be loaded from the cache. */
236
+ preload: (url: UrlResolvable, options?: Omit<HybridRequestOptions, 'method' | 'url'>) => Promise<boolean>;
237
+ /** Determines if the given route name and parameters matches the current route. */
238
+ matches: <T extends RouteName>(name: T, parameters?: RouteParameters<T>) => boolean;
239
+ /** Gets the current route name. Returns `undefined` is unknown. */
240
+ current: () => string | undefined;
231
241
  /** Access the dialog router. */
232
242
  dialog: DialogRouter;
233
243
  /** Access the history state. */
@@ -368,6 +378,8 @@ interface InternalRouterContext {
368
378
  routing?: RoutingConfiguration;
369
379
  /** Whether to display response error modals. */
370
380
  responseErrorModals?: boolean;
381
+ /** Cache of preload requests. */
382
+ preloadCache: Map<string, AxiosResponse>;
371
383
  }
372
384
  /** Router context. */
373
385
  type RouterContext = Readonly<InternalRouterContext>;
@@ -382,7 +394,7 @@ interface Adapter {
382
394
  /** Called when a dialog is closed. */
383
395
  onDialogClose?: (context: InternalRouterContext) => void;
384
396
  /** Called when Hybridly is waiting for a component to be mounted. The given callback should be executed after the view component is mounted. */
385
- onWaitingForMount: (callback: Function) => void;
397
+ executeOnMounted: (callback: Function) => void;
386
398
  }
387
399
  interface ResolvedAdapter extends Adapter {
388
400
  updateRoutingConfiguration: (routing?: RoutingConfiguration) => void;
@@ -426,10 +438,6 @@ declare function can<Authorizations extends Record<string, boolean>, Data extend
426
438
  * Generates a route from the given route name.
427
439
  */
428
440
  declare function route<T extends RouteName>(name: T, parameters?: RouteParameters<T>, absolute?: boolean): string;
429
- /**
430
- * Determines if the current route correspond to the given route name and parameters.
431
- */
432
- declare function current<T extends RouteName>(name: T, parameters?: RouteParameters<T>, mode?: 'loose' | 'strict'): boolean;
433
441
 
434
442
  interface DynamicConfiguration {
435
443
  architecture: {
@@ -492,4 +500,4 @@ declare namespace constants {
492
500
  };
493
501
  }
494
502
 
495
- export { Authorizable, DynamicConfiguration, GlobalRouteCollection, HybridPayload, HybridRequestOptions, MaybePromise, Method, NavigationResponse, Plugin, Progress, ResolveComponent, RouteDefinition, RouteName, RouteParameters, Router, RouterContext, RouterContextOptions, RoutingConfiguration, UrlResolvable, can, constants, createRouter, current, definePlugin, getRouterContext, makeUrl, registerHook, route, router, sameUrls };
503
+ export { Authorizable, DynamicConfiguration, 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 { debug, merge, removeTrailingSlash, debounce, random, hasFiles, objectToFormData, when, match, showResponseErrorModal } from '@hybridly/utils';
1
+ import { debug, merge, removeTrailingSlash, debounce, random, hasFiles, objectToFormData, match, showResponseErrorModal, when } from '@hybridly/utils';
2
2
  import axios from 'axios';
3
3
  import { stringify, parse } from 'superjson';
4
4
  import qs from 'qs';
@@ -151,7 +151,7 @@ async function restoreScrollPositions() {
151
151
  debug.scroll("No region found to restore.");
152
152
  return;
153
153
  }
154
- context.adapter.onWaitingForMount(() => {
154
+ context.adapter.executeOnMounted(() => {
155
155
  debug.scroll(`Restoring ${regions.length}/${context.scrollRegions.length} region(s).`);
156
156
  regions.forEach((el, i) => el.scrollTo({
157
157
  top: context.scrollRegions.at(i)?.top ?? el.scrollTop,
@@ -351,10 +351,59 @@ function createSerializer(options) {
351
351
  };
352
352
  }
353
353
 
354
+ function getUrlRegexForRoute(name) {
355
+ const routing = getRouting();
356
+ const definition = getRouteDefinition(name);
357
+ const path = definition.uri.replaceAll("/", "\\/");
358
+ const domain = definition.domain;
359
+ const protocolPrefix = routing.url.match(/^\w+:\/\//)?.[0];
360
+ const origin = domain ? `${protocolPrefix}${domain}${routing.port ? `:${routing.port}` : ""}`.replaceAll("/", "\\/") : routing.url.replaceAll("/", "\\/");
361
+ const urlPathRegexPattern = path.length > 0 ? `\\/${path.replace(/\/$/g, "")}` : "";
362
+ let urlRegexPattern = `^${origin.replaceAll(".", "\\.")}${urlPathRegexPattern}\\/?(\\?.*)?$`;
363
+ urlRegexPattern = urlRegexPattern.replace(/(\\\/?){([^}?]+)(\??)}/g, (_, slash, parameterName, optional) => {
364
+ const where = definition.wheres?.[parameterName];
365
+ let regexTemplate = where?.replace(/(^\^)|(\$$)/g, "") || "[^/?]+";
366
+ regexTemplate = `(?<${parameterName}>${regexTemplate})`;
367
+ if (optional) {
368
+ return `(${slash ? "\\/?" : ""}${regexTemplate})?`;
369
+ }
370
+ return (slash ? "\\/" : "") + regexTemplate;
371
+ });
372
+ return RegExp(urlRegexPattern);
373
+ }
374
+ function urlMatchesRoute(url, name, routeParameters) {
375
+ const parameters = routeParameters || {};
376
+ const definition = getRouting().routes[name];
377
+ if (!definition) {
378
+ return false;
379
+ }
380
+ const matches = getUrlRegexForRoute(name).exec(url);
381
+ if (!matches) {
382
+ return false;
383
+ }
384
+ for (const k in matches.groups) {
385
+ matches.groups[k] = typeof matches.groups[k] === "string" ? decodeURIComponent(matches.groups[k]) : matches.groups[k];
386
+ }
387
+ return Object.keys(parameters).every((parameterName) => {
388
+ let value = parameters[parameterName];
389
+ const bindingProperty = definition.bindings?.[parameterName];
390
+ if (bindingProperty && typeof value === "object") {
391
+ value = value[bindingProperty];
392
+ }
393
+ return matches.groups?.[parameterName] === value.toString();
394
+ });
395
+ }
354
396
  function generateRouteFromName(name, parameters, absolute, shouldThrow) {
355
397
  const url = getUrlFromName(name, parameters, shouldThrow);
356
398
  return absolute === false ? url.toString().replace(url.origin, "") : url.toString();
357
399
  }
400
+ function getNameFromUrl(url, parameters) {
401
+ const routing = getRouting();
402
+ const routes = Object.values(routing.routes).map((x) => x.name);
403
+ return routes.find((routeName) => {
404
+ return urlMatchesRoute(url, routeName, parameters);
405
+ });
406
+ }
358
407
  function getUrlFromName(name, parameters, shouldThrow) {
359
408
  const routing = getRouting();
360
409
  const definition = getRouteDefinition(name);
@@ -367,32 +416,39 @@ function getUrlFromName(name, parameters, shouldThrow) {
367
416
  }));
368
417
  return url;
369
418
  }
370
- function getRouteTransformable(routeName, routeParameters, shouldThrow) {
419
+ function getRouteParameterValue(routeName, parameterName, routeParameters) {
371
420
  const routing = getRouting();
421
+ const definition = getRouteDefinition(routeName);
422
+ const parameters = routeParameters || {};
423
+ const value = (() => {
424
+ const value2 = parameters[parameterName];
425
+ const bindingProperty = definition.bindings?.[parameterName];
426
+ if (bindingProperty && value2 != null && typeof value2 === "object") {
427
+ return value2[bindingProperty];
428
+ }
429
+ return value2;
430
+ })();
431
+ if (value) {
432
+ const where = definition.wheres?.[parameterName];
433
+ if (where && !new RegExp(where).test(value)) {
434
+ console.warn(`[hybridly:routing] Parameter [${parameterName}] does not match the required format [${where}] for route [${routeName}].`);
435
+ }
436
+ return value;
437
+ }
438
+ if (routing.defaults?.[parameterName]) {
439
+ return routing.defaults?.[parameterName];
440
+ }
441
+ }
442
+ function getRouteTransformable(routeName, routeParameters, shouldThrow) {
372
443
  const definition = getRouteDefinition(routeName);
373
444
  const parameters = routeParameters || {};
374
445
  const missing = Object.keys(parameters);
375
- const replaceParameter = (match, parameterName) => {
376
- const optional = /\?}$/.test(match);
377
- const value = (() => {
378
- const value2 = parameters[parameterName];
379
- const bindingProperty = definition.bindings?.[parameterName];
380
- if (bindingProperty && typeof value2 === "object") {
381
- return value2[bindingProperty];
382
- }
383
- return value2;
384
- })();
446
+ const replaceParameter = (match, parameterName, optional) => {
447
+ const value = getRouteParameterValue(routeName, parameterName, parameters);
385
448
  missing.splice(missing.indexOf(parameterName), 1);
386
449
  if (value) {
387
- const where = definition.wheres?.[parameterName];
388
- if (where && !new RegExp(where).test(value)) {
389
- console.warn(`[hybridly:routing] Parameter [${parameterName}] does not match the required format [${where}] for route [${routeName}].`);
390
- }
391
450
  return value;
392
451
  }
393
- if (routing.defaults?.[parameterName]) {
394
- return routing.defaults?.[parameterName];
395
- }
396
452
  if (optional) {
397
453
  return "";
398
454
  }
@@ -401,8 +457,8 @@ function getRouteTransformable(routeName, routeParameters, shouldThrow) {
401
457
  }
402
458
  throw new MissingRouteParameter(parameterName, routeName);
403
459
  };
404
- const path = definition.uri.replace(/{([^}?]+)\??}/g, replaceParameter);
405
- const domain = definition.domain?.replace(/{([^}?]+)\??}/g, replaceParameter);
460
+ const path = definition.uri.replace(/{([^}?]+)(\??)}/g, replaceParameter);
461
+ const domain = definition.domain?.replace(/{([^}?]+)(\??)}/g, replaceParameter);
406
462
  const remaining = Object.keys(parameters).filter((key) => missing.includes(key)).reduce((obj, key) => ({
407
463
  ...obj,
408
464
  [key]: parameters[key]
@@ -432,30 +488,28 @@ function getRouting() {
432
488
  }
433
489
  return routing;
434
490
  }
435
-
436
- function isCurrentFromName(name, parameters, mode = "loose") {
437
- const location = window.location;
438
- const matchee = (() => {
439
- try {
440
- return makeUrl(generateRouteFromName(name, parameters, true, false));
441
- } catch (error) {
442
- }
443
- })();
444
- if (!matchee) {
445
- return false;
446
- }
447
- if (mode === "strict") {
448
- return location.href === matchee.href;
449
- }
450
- return location.href.startsWith(matchee.href);
451
- }
452
-
453
491
  function route(name, parameters, absolute) {
454
492
  return generateRouteFromName(name, parameters, absolute);
455
493
  }
456
- function current(name, parameters, mode = "loose") {
457
- return isCurrentFromName(name, parameters, mode);
494
+
495
+ function getCurrentUrl() {
496
+ if (typeof window === "undefined") {
497
+ return getInternalRouterContext().url;
498
+ }
499
+ return window.location.toString();
500
+ }
501
+ function currentRouteMatches(name, parameters) {
502
+ const namePattern = name.replaceAll(".", "\\.").replaceAll("*", ".*");
503
+ const possibleRoutes = Object.values(getRouting().routes).filter((x) => x.method.includes("GET") && RegExp(namePattern).test(x.name)).map((x) => x.name);
504
+ const currentUrl = getCurrentUrl();
505
+ return possibleRoutes.some((routeName) => {
506
+ return urlMatchesRoute(currentUrl, routeName, parameters);
507
+ });
508
+ }
509
+ function getCurrentRouteName() {
510
+ return getNameFromUrl(getCurrentUrl());
458
511
  }
512
+
459
513
  function updateRoutingConfiguration(routing) {
460
514
  if (!routing) {
461
515
  return;
@@ -491,6 +545,7 @@ async function initializeContext(options) {
491
545
  plugins: options.plugins ?? [],
492
546
  axios: options.axios ?? axios.create(),
493
547
  routing: options.routing,
548
+ preloadCache: /* @__PURE__ */ new Map(),
494
549
  hooks: {},
495
550
  memo: {}
496
551
  };
@@ -581,6 +636,52 @@ async function closeDialog(options) {
581
636
  });
582
637
  }
583
638
 
639
+ function isPreloaded(targetUrl) {
640
+ const context = getInternalRouterContext();
641
+ return context.preloadCache.has(targetUrl.toString()) ?? false;
642
+ }
643
+ function getPreloadedRequest(targetUrl) {
644
+ const context = getInternalRouterContext();
645
+ return context.preloadCache.get(targetUrl.toString());
646
+ }
647
+ function storePreloadRequest(targetUrl, response) {
648
+ const context = getInternalRouterContext();
649
+ context.preloadCache.set(targetUrl.toString(), response);
650
+ }
651
+ function discardPreloadedRequest(targetUrl) {
652
+ const context = getInternalRouterContext();
653
+ return context.preloadCache.delete(targetUrl.toString());
654
+ }
655
+ async function performPreloadRequest(options) {
656
+ const context = getRouterContext();
657
+ const url = makeUrl(options.url ?? context.url);
658
+ if (isPreloaded(url)) {
659
+ debug.router("This request is already preloaded.");
660
+ return false;
661
+ }
662
+ if (context.pendingNavigation) {
663
+ debug.router("A navigation is pending, preload aborted.");
664
+ return false;
665
+ }
666
+ if (options.method !== "GET") {
667
+ debug.router("Cannot preload non-GET requests.");
668
+ return false;
669
+ }
670
+ debug.router(`Preloading response for [${url.toString()}]`);
671
+ try {
672
+ const response = await performHybridRequest(url, options);
673
+ if (!isHybridResponse(response)) {
674
+ debug.router("Preload result was invalid.");
675
+ return false;
676
+ }
677
+ storePreloadRequest(url, response);
678
+ return true;
679
+ } catch (error) {
680
+ debug.router("Preloading failed.");
681
+ return false;
682
+ }
683
+ }
684
+
584
685
  const router = {
585
686
  abort: async () => getRouterContext().pendingNavigation?.controller.abort(),
586
687
  active: () => !!getRouterContext().pendingNavigation,
@@ -592,12 +693,15 @@ const router = {
592
693
  patch: async (url, options = {}) => await performHybridNavigation({ preserveState: true, ...options, url, method: "PATCH" }),
593
694
  delete: async (url, options = {}) => await performHybridNavigation({ preserveState: true, ...options, url, method: "DELETE" }),
594
695
  local: async (url, options = {}) => await performLocalNavigation(url, options),
696
+ preload: async (url, options = {}) => await performPreloadRequest({ ...options, url, method: "GET" }),
595
697
  external: (url, data = {}) => navigateToExternalUrl(url, data),
596
698
  to: async (name, parameters, options) => {
597
699
  const url = generateRouteFromName(name, parameters);
598
700
  const method = getRouteDefinition(name).method.at(0);
599
701
  return await performHybridNavigation({ url, ...options, method });
600
702
  },
703
+ matches: (name, parameters) => currentRouteMatches(name, parameters),
704
+ current: () => getCurrentRouteName(),
601
705
  dialog: {
602
706
  close: (options) => closeDialog(options)
603
707
  },
@@ -623,11 +727,6 @@ async function performHybridNavigation(options) {
623
727
  if ((hasFiles(options.data) || options.useFormData) && !(options.data instanceof FormData)) {
624
728
  options.data = objectToFormData(options.data);
625
729
  debug.router("Converted data to FormData.", options.data);
626
- if (options.method && ["PUT", "PATCH", "DELETE"].includes(options.method) && options.spoof !== false) {
627
- debug.router(`Automatically spoofing method ${options.method}.`);
628
- options.data.append("_method", options.method);
629
- options.method = "POST";
630
- }
631
730
  }
632
731
  if (!(options.data instanceof FormData) && options.method === "GET" && Object.keys(options.data ?? {}).length) {
633
732
  debug.router("Transforming data to query parameters.", options.data);
@@ -636,6 +735,19 @@ async function performHybridNavigation(options) {
636
735
  });
637
736
  options.data = {};
638
737
  }
738
+ if (["PUT", "PATCH", "DELETE"].includes(options.method) && options.spoof !== false) {
739
+ debug.router(`Automatically spoofing method ${options.method}.`);
740
+ if (options.data instanceof FormData) {
741
+ options.data.append("_method", options.method);
742
+ } else if (Object.keys(options.data ?? {}).length) {
743
+ Object.assign(options.data, { _method: options.method });
744
+ } else if (typeof options.data === "undefined") {
745
+ options.data = { _method: options.method };
746
+ } else {
747
+ debug.router("Could not spoof method because body type is not supported.", options.data);
748
+ }
749
+ options.method = "POST";
750
+ }
639
751
  if (!await runHooks("before", options.hooks, options, context)) {
640
752
  debug.router('"before" event returned false, aborting the navigation.');
641
753
  throw new NavigationCancelledError('The navigation was cancelled by the "before" event.');
@@ -661,35 +773,7 @@ async function performHybridNavigation(options) {
661
773
  });
662
774
  await runHooks("start", options.hooks, context);
663
775
  debug.router("Making request with axios.");
664
- const response = await context.axios.request({
665
- url: targetUrl.toString(),
666
- method: options.method,
667
- data: options.method === "GET" ? {} : options.data,
668
- params: options.method === "GET" ? options.data : {},
669
- signal: abortController.signal,
670
- headers: {
671
- ...options.headers,
672
- ...context.dialog ? { [DIALOG_KEY_HEADER]: context.dialog.key } : {},
673
- ...context.dialog ? { [DIALOG_REDIRECT_HEADER]: context.dialog.redirectUrl ?? "" } : {},
674
- ...when(options.only !== void 0 || options.except !== void 0, {
675
- [PARTIAL_COMPONENT_HEADER]: context.view.component,
676
- ...when(options.only, { [ONLY_DATA_HEADER]: JSON.stringify(options.only) }, {}),
677
- ...when(options.except, { [EXCEPT_DATA_HEADER]: JSON.stringify(options.except) }, {})
678
- }, {}),
679
- ...when(options.errorBag, { [ERROR_BAG_HEADER]: options.errorBag }, {}),
680
- ...when(context.version, { [VERSION_HEADER]: context.version }, {}),
681
- [HYBRIDLY_HEADER]: true,
682
- "X-Requested-With": "XMLHttpRequest",
683
- "Accept": "text/html, application/xhtml+xml"
684
- },
685
- validateStatus: () => true,
686
- onUploadProgress: async (event) => {
687
- await runHooks("progress", options.hooks, {
688
- event,
689
- percentage: Math.round(event.loaded / (event.total ?? 0) * 100)
690
- }, context);
691
- }
692
- });
776
+ const response = await performHybridRequest(targetUrl, options, abortController);
693
777
  await runHooks("data", options.hooks, response, context);
694
778
  if (isExternalResponse(response)) {
695
779
  debug.router("The response is explicitely external.");
@@ -833,6 +917,47 @@ async function navigate(options) {
833
917
  resetScrollPositions();
834
918
  }
835
919
  await runHooks("navigated", {}, options, context);
920
+ context.adapter.executeOnMounted(() => {
921
+ runHooks("mounted", {}, context);
922
+ });
923
+ }
924
+ async function performHybridRequest(targetUrl, options, abortController) {
925
+ const context = getInternalRouterContext();
926
+ const preloaded = getPreloadedRequest(targetUrl);
927
+ if (preloaded) {
928
+ debug.router(`Found a pre-loaded request for [${targetUrl}]`);
929
+ discardPreloadedRequest(targetUrl);
930
+ return preloaded;
931
+ }
932
+ return await context.axios.request({
933
+ url: targetUrl.toString(),
934
+ method: options.method,
935
+ data: options.method === "GET" ? {} : options.data,
936
+ params: options.method === "GET" ? options.data : {},
937
+ signal: abortController?.signal,
938
+ headers: {
939
+ ...options.headers,
940
+ ...context.dialog ? { [DIALOG_KEY_HEADER]: context.dialog.key } : {},
941
+ ...context.dialog ? { [DIALOG_REDIRECT_HEADER]: context.dialog.redirectUrl ?? "" } : {},
942
+ ...when(options.only !== void 0 || options.except !== void 0, {
943
+ [PARTIAL_COMPONENT_HEADER]: context.view.component,
944
+ ...when(options.only, { [ONLY_DATA_HEADER]: JSON.stringify(options.only) }, {}),
945
+ ...when(options.except, { [EXCEPT_DATA_HEADER]: JSON.stringify(options.except) }, {})
946
+ }, {}),
947
+ ...when(options.errorBag, { [ERROR_BAG_HEADER]: options.errorBag }, {}),
948
+ ...when(context.version, { [VERSION_HEADER]: context.version }, {}),
949
+ [HYBRIDLY_HEADER]: true,
950
+ "X-Requested-With": "XMLHttpRequest",
951
+ "Accept": "text/html, application/xhtml+xml"
952
+ },
953
+ validateStatus: () => true,
954
+ onUploadProgress: async (event) => {
955
+ await runHooks("progress", options.hooks, {
956
+ event,
957
+ percentage: Math.round(event.loaded / (event.total ?? 0) * 100)
958
+ }, context);
959
+ }
960
+ });
836
961
  }
837
962
  async function initializeRouter() {
838
963
  const context = getRouterContext();
@@ -875,4 +1000,4 @@ function can(resource, action) {
875
1000
  return resource.authorization?.[action] ?? false;
876
1001
  }
877
1002
 
878
- export { can, constants, createRouter, current, definePlugin, getRouterContext, makeUrl, registerHook, route, router, sameUrls };
1003
+ 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.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Core functionality of Hybridly",
5
5
  "keywords": [
6
6
  "hybridly",
@@ -37,8 +37,8 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "qs": "^6.11.2",
40
- "superjson": "^1.12.3",
41
- "@hybridly/utils": "0.4.0"
40
+ "superjson": "^1.12.4",
41
+ "@hybridly/utils": "0.4.2"
42
42
  },
43
43
  "devDependencies": {
44
44
  "defu": "^6.1.2"