@hybridly/core 0.8.3 → 0.10.0-beta.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.mjs CHANGED
@@ -1,8 +1,24 @@
1
- import { debug, merge, removeTrailingSlash, debounce, random, hasFiles, objectToFormData, match, showResponseErrorModal, when } from '@hybridly/utils';
2
- import qs from 'qs';
3
- import axios from 'axios';
4
- import { parse, stringify } from 'superjson';
1
+ import { t as __exportAll } from "./_chunks/chunk.mjs";
2
+ import { debounce, debug, hasFiles, match, merge, objectToFormData, random, removeTrailingSlash, showResponseErrorModal, when } from "@hybridly/utils";
3
+ import axios from "axios";
4
+ import { parse, stringify } from "superjson";
5
+ import qs from "qs";
5
6
 
7
+ //#region src/constants.ts
8
+ var constants_exports = /* @__PURE__ */ __exportAll({
9
+ DIALOG_KEY_HEADER: () => DIALOG_KEY_HEADER,
10
+ DIALOG_REDIRECT_HEADER: () => DIALOG_REDIRECT_HEADER,
11
+ ERROR_BAG_HEADER: () => ERROR_BAG_HEADER,
12
+ EXCEPT_DATA_HEADER: () => EXCEPT_DATA_HEADER,
13
+ EXTERNAL_NAVIGATION_HEADER: () => EXTERNAL_NAVIGATION_HEADER,
14
+ EXTERNAL_NAVIGATION_TARGET_HEADER: () => EXTERNAL_NAVIGATION_TARGET_HEADER,
15
+ HYBRIDLY_HEADER: () => HYBRIDLY_HEADER,
16
+ ONLY_DATA_HEADER: () => ONLY_DATA_HEADER,
17
+ PARTIAL_COMPONENT_HEADER: () => PARTIAL_COMPONENT_HEADER,
18
+ SCROLL_REGION_ATTRIBUTE: () => SCROLL_REGION_ATTRIBUTE,
19
+ STORAGE_EXTERNAL_KEY: () => STORAGE_EXTERNAL_KEY,
20
+ VERSION_HEADER: () => VERSION_HEADER
21
+ });
6
22
  const STORAGE_EXTERNAL_KEY = "hybridly:external";
7
23
  const HYBRIDLY_HEADER = "x-hybrid";
8
24
  const EXTERNAL_NAVIGATION_HEADER = `${HYBRIDLY_HEADER}-external`;
@@ -16,1077 +32,1158 @@ const VERSION_HEADER = `${HYBRIDLY_HEADER}-version`;
16
32
  const ERROR_BAG_HEADER = `${HYBRIDLY_HEADER}-error-bag`;
17
33
  const SCROLL_REGION_ATTRIBUTE = "scroll-region";
18
34
 
19
- const constants = {
20
- __proto__: null,
21
- DIALOG_KEY_HEADER: DIALOG_KEY_HEADER,
22
- DIALOG_REDIRECT_HEADER: DIALOG_REDIRECT_HEADER,
23
- ERROR_BAG_HEADER: ERROR_BAG_HEADER,
24
- EXCEPT_DATA_HEADER: EXCEPT_DATA_HEADER,
25
- EXTERNAL_NAVIGATION_HEADER: EXTERNAL_NAVIGATION_HEADER,
26
- EXTERNAL_NAVIGATION_TARGET_HEADER: EXTERNAL_NAVIGATION_TARGET_HEADER,
27
- HYBRIDLY_HEADER: HYBRIDLY_HEADER,
28
- ONLY_DATA_HEADER: ONLY_DATA_HEADER,
29
- PARTIAL_COMPONENT_HEADER: PARTIAL_COMPONENT_HEADER,
30
- SCROLL_REGION_ATTRIBUTE: SCROLL_REGION_ATTRIBUTE,
31
- STORAGE_EXTERNAL_KEY: STORAGE_EXTERNAL_KEY,
32
- VERSION_HEADER: VERSION_HEADER
35
+ //#endregion
36
+ //#region src/errors.ts
37
+ var NotAHybridResponseError = class extends Error {
38
+ constructor(response) {
39
+ super();
40
+ this.response = response;
41
+ }
42
+ };
43
+ var NavigationCancelledError = class extends Error {};
44
+ var RoutingNotInitialized = class extends Error {
45
+ constructor() {
46
+ super("Routing is not initialized. Make sure the Vite plugin is enabled and that `php artisan route:list` returns no error.");
47
+ }
48
+ };
49
+ var RouteNotFound = class extends Error {
50
+ constructor(name) {
51
+ super(`Route [${name}] does not exist.`);
52
+ }
53
+ };
54
+ var MissingRouteParameter = class extends Error {
55
+ constructor(parameter, routeName) {
56
+ super(`Parameter [${parameter}] is required for route [${routeName}].`);
57
+ }
33
58
  };
34
59
 
35
- class NotAHybridResponseError extends Error {
36
- constructor(response) {
37
- super();
38
- this.response = response;
39
- }
40
- }
41
- class NavigationCancelledError extends Error {
42
- }
43
- class RoutingNotInitialized extends Error {
44
- constructor() {
45
- super("Routing is not initialized. Make sure the Vite plugin is enabled and that `php artisan route:list` returns no error.");
46
- }
47
- }
48
- class RouteNotFound extends Error {
49
- constructor(name) {
50
- super(`Route [${name}] does not exist.`);
51
- }
52
- }
53
- class MissingRouteParameter extends Error {
54
- constructor(parameter, routeName) {
55
- super(`Parameter [${parameter}] is required for route [${routeName}].`);
56
- }
57
- }
58
-
60
+ //#endregion
61
+ //#region src/plugins/plugin.ts
59
62
  function definePlugin(plugin) {
60
- return plugin;
63
+ return plugin;
61
64
  }
62
65
  async function forEachPlugin(cb) {
63
- const { plugins } = getRouterContext();
64
- for (const plugin of plugins) {
65
- await cb(plugin);
66
- }
66
+ const { plugins } = getRouterContext();
67
+ for (const plugin of plugins) await cb(plugin);
67
68
  }
68
69
  async function runPluginHooks(hook, ...args) {
69
- let result = true;
70
- await forEachPlugin(async (plugin) => {
71
- if (plugin[hook]) {
72
- debug.plugin(plugin.name, `Calling "${hook}" hook.`);
73
- result &&= await plugin[hook]?.(...args) !== false;
74
- }
75
- });
76
- return result;
70
+ let result = true;
71
+ await forEachPlugin(async (plugin) => {
72
+ if (plugin[hook]) {
73
+ debug.plugin(plugin.name, `Calling "${hook}" hook.`);
74
+ result &&= await plugin[hook]?.(...args) !== false;
75
+ }
76
+ });
77
+ return result;
77
78
  }
78
79
  async function runGlobalHooks(hook, ...args) {
79
- const { hooks } = getRouterContext();
80
- if (!hooks[hook]) {
81
- return true;
82
- }
83
- let result = true;
84
- for (const fn of hooks[hook]) {
85
- debug.hook(`Calling global "${hook}" hooks.`);
86
- result = await fn(...args) ?? result;
87
- }
88
- return result;
80
+ const { hooks } = getRouterContext();
81
+ if (!hooks[hook]) return true;
82
+ let result = true;
83
+ for (const fn of hooks[hook]) {
84
+ debug.hook(`Calling global "${hook}" hooks.`);
85
+ result = await fn(...args) ?? result;
86
+ }
87
+ return result;
89
88
  }
90
89
  async function runHooks(hook, requestHooks, ...args) {
91
- const result = await Promise.all([
92
- requestHooks?.[hook]?.(...args),
93
- runGlobalHooks(hook, ...args),
94
- runPluginHooks(hook, ...args)
95
- ]);
96
- debug.hook(`Called all hooks for [${hook}],`, result);
97
- return !result.includes(false);
90
+ const result = await Promise.all([
91
+ requestHooks?.[hook]?.(...args),
92
+ runGlobalHooks(hook, ...args),
93
+ runPluginHooks(hook, ...args)
94
+ ]);
95
+ debug.hook(`Called all hooks for [${hook}],`, result);
96
+ return !result.includes(false);
98
97
  }
99
98
 
99
+ //#endregion
100
+ //#region src/plugins/hooks.ts
101
+ /**
102
+ * Registers a global hook.
103
+ */
100
104
  function appendCallbackToHooks(hook, fn) {
101
- const hooks = getRouterContext().hooks;
102
- hooks[hook] = [...hooks[hook] ?? [], fn];
103
- return () => {
104
- const index = hooks[hook].indexOf(fn);
105
- if (index !== -1) {
106
- hooks[hook]?.splice(index, 1);
107
- }
108
- };
109
- }
105
+ const hooks = getRouterContext().hooks;
106
+ hooks[hook] = [...hooks[hook] ?? [], fn];
107
+ return () => {
108
+ const index = hooks[hook].indexOf(fn);
109
+ if (index !== -1) hooks[hook]?.splice(index, 1);
110
+ };
111
+ }
112
+ /**
113
+ * Registers a global hook.
114
+ */
110
115
  function registerHook(hook, fn, options) {
111
- if (options?.once) {
112
- const unregister = appendCallbackToHooks(hook, async (...args) => {
113
- await fn(...args);
114
- unregister();
115
- });
116
- return unregister;
117
- }
118
- return appendCallbackToHooks(hook, fn);
116
+ if (options?.once) {
117
+ const unregister = appendCallbackToHooks(hook, async (...args) => {
118
+ await fn(...args);
119
+ unregister();
120
+ });
121
+ return unregister;
122
+ }
123
+ return appendCallbackToHooks(hook, fn);
119
124
  }
120
125
 
126
+ //#endregion
127
+ //#region src/scroll.ts
128
+ /** Saves the current view's scrollbar positions into the history state. */
121
129
  function saveScrollPositions() {
122
- const regions = getScrollRegions();
123
- debug.scroll("Saving scroll positions of:", regions.map((el) => ({ el, scroll: { top: el.scrollTop, left: el.scrollLeft } })));
124
- setContext({
125
- scrollRegions: regions.map(({ scrollTop, scrollLeft }) => ({
126
- top: scrollTop,
127
- left: scrollLeft
128
- }))
129
- });
130
- setHistoryState({ replace: true });
131
- }
130
+ const regions = getScrollRegions();
131
+ debug.scroll("Saving scroll positions of:", regions.map((el) => ({
132
+ el,
133
+ scroll: {
134
+ top: el.scrollTop,
135
+ left: el.scrollLeft
136
+ }
137
+ })));
138
+ setContext({ scrollRegions: regions.map(({ scrollTop, scrollLeft }) => ({
139
+ top: scrollTop,
140
+ left: scrollLeft
141
+ })) });
142
+ setHistoryState({ replace: true });
143
+ }
144
+ /** Gets DOM elements which scroll positions should be saved. */
132
145
  function getScrollRegions() {
133
- return Array.from(document?.querySelectorAll(`[${SCROLL_REGION_ATTRIBUTE}]`) ?? []).concat(document.documentElement, document.body);
146
+ return Array.from(document?.querySelectorAll(`[${SCROLL_REGION_ATTRIBUTE}]`) ?? []).concat(document.documentElement, document.body);
134
147
  }
148
+ /**
149
+ * Resets the current view's scrollbars positions to the top, and save them
150
+ * in the history state.
151
+ */
135
152
  function resetScrollPositions() {
136
- debug.scroll("Resetting scroll positions.");
137
- getScrollRegions().forEach((element) => element.scrollTo({
138
- top: 0,
139
- left: 0
140
- }));
141
- saveScrollPositions();
142
- if (window.location.hash) {
143
- debug.scroll(`Hash is present, scrolling to the element of ID ${window.location.hash}.`);
144
- document.getElementById(window.location.hash.slice(1))?.scrollIntoView();
145
- }
146
- }
153
+ debug.scroll("Resetting scroll positions.");
154
+ getScrollRegions().forEach((element) => element.scrollTo({
155
+ top: 0,
156
+ left: 0
157
+ }));
158
+ saveScrollPositions();
159
+ if (window.location.hash) {
160
+ debug.scroll(`Hash is present, scrolling to the element of ID ${window.location.hash}.`);
161
+ document.getElementById(window.location.hash.slice(1))?.scrollIntoView();
162
+ }
163
+ }
164
+ /** Restores the scroll positions stored in the context. */
147
165
  async function restoreScrollPositions() {
148
- const context = getRouterContext();
149
- const regions = getScrollRegions();
150
- if (!context.scrollRegions) {
151
- debug.scroll("No region found to restore.");
152
- return;
153
- }
154
- context.adapter.executeOnMounted(() => {
155
- debug.scroll(`Restoring ${regions.length}/${context.scrollRegions.length} region(s).`);
156
- regions.forEach((el, i) => el.scrollTo({
157
- top: context.scrollRegions.at(i)?.top ?? el.scrollTop,
158
- left: context.scrollRegions.at(i)?.top ?? el.scrollLeft
159
- }));
160
- });
166
+ const context = getRouterContext();
167
+ const regions = getScrollRegions();
168
+ if (!context.scrollRegions) {
169
+ debug.scroll("No region found to restore.");
170
+ return;
171
+ }
172
+ context.adapter.executeOnMounted(() => {
173
+ debug.scroll(`Restoring ${regions.length}/${context.scrollRegions.length} region(s).`);
174
+ regions.forEach((el, i) => el.scrollTo({
175
+ top: context.scrollRegions.at(i)?.top ?? el.scrollTop,
176
+ left: context.scrollRegions.at(i)?.top ?? el.scrollLeft
177
+ }));
178
+ });
161
179
  }
162
180
 
181
+ //#endregion
182
+ //#region src/url.ts
183
+ /** Normalizes the given input to an URL. */
163
184
  function normalizeUrl(href, trailingSlash) {
164
- return makeUrl(href, { trailingSlash }).toString();
185
+ return makeUrl(href, { trailingSlash }).toString();
165
186
  }
187
+ /**
188
+ * Converts an input to an URL, optionally changing its properties after initialization.
189
+ */
166
190
  function makeUrl(href, transformations = {}) {
167
- try {
168
- const base = document?.location?.href === "//" ? void 0 : document.location.href;
169
- const url = new URL(String(href), base);
170
- transformations = typeof transformations === "function" ? transformations(url) ?? {} : transformations ?? {};
171
- Object.entries(transformations).forEach(([key, value]) => {
172
- if (key === "query") {
173
- const currentQueryParameters = merge(
174
- qs.parse(url.search, { ignoreQueryPrefix: true }),
175
- value,
176
- { mergePlainObjects: true }
177
- );
178
- key = "search";
179
- value = qs.stringify(currentQueryParameters, {
180
- encodeValuesOnly: true,
181
- arrayFormat: "brackets",
182
- filter: (_, object) => object instanceof Set ? [...object] : object
183
- });
184
- }
185
- Reflect.set(url, key, value);
186
- });
187
- if (transformations.trailingSlash === false) {
188
- const _url = removeTrailingSlash(url.toString().replace(/\/\?/, "?"));
189
- url.toString = () => _url;
190
- }
191
- return url;
192
- } catch (error) {
193
- throw new TypeError(`${href} is not resolvable to a valid URL.`);
194
- }
195
- }
191
+ try {
192
+ const base = document?.location?.href === "//" ? void 0 : document.location.href;
193
+ const url = new URL(String(href), base);
194
+ transformations = typeof transformations === "function" ? transformations(url) ?? {} : transformations ?? {};
195
+ Object.entries(transformations).forEach(([key, value]) => {
196
+ if (key === "query") {
197
+ const currentQueryParameters = merge(qs.parse(url.search, { ignoreQueryPrefix: true }), value, { mergePlainObjects: true });
198
+ key = "search";
199
+ value = qs.stringify(currentQueryParameters, {
200
+ encodeValuesOnly: true,
201
+ arrayFormat: "brackets",
202
+ filter: (_, object) => object instanceof Set ? [...object] : object
203
+ });
204
+ }
205
+ Reflect.set(url, key, value);
206
+ });
207
+ if (transformations.trailingSlash === false) {
208
+ const _url = removeTrailingSlash(url.toString().replace(/\/\?/, "?"));
209
+ url.toString = () => _url;
210
+ }
211
+ return url;
212
+ } catch (error) {
213
+ throw new TypeError(`${href} is not resolvable to a valid URL.`);
214
+ }
215
+ }
216
+ /**
217
+ * Checks if the given URLs have the same origin and path.
218
+ */
196
219
  function sameUrls(...hrefs) {
197
- if (hrefs.length < 2) {
198
- return true;
199
- }
200
- try {
201
- return hrefs.every((href) => {
202
- return makeUrl(href, { hash: "" }).toJSON() === makeUrl(hrefs.at(0), { hash: "" }).toJSON();
203
- });
204
- } catch {
205
- }
206
- return false;
207
- }
220
+ if (hrefs.length < 2) return true;
221
+ try {
222
+ return hrefs.every((href) => {
223
+ return makeUrl(href, { hash: "" }).toJSON() === makeUrl(hrefs.at(0), { hash: "" }).toJSON();
224
+ });
225
+ } catch {}
226
+ return false;
227
+ }
228
+ /**
229
+ * Checks if the given URLs have the same origin, path, and hash.
230
+ */
208
231
  function sameHashes(...hrefs) {
209
- if (hrefs.length < 2) {
210
- return true;
211
- }
212
- try {
213
- return hrefs.every((href) => {
214
- return makeUrl(href).toJSON() === makeUrl(hrefs.at(0)).toJSON();
215
- });
216
- } catch {
217
- }
218
- return false;
219
- }
232
+ if (hrefs.length < 2) return true;
233
+ try {
234
+ return hrefs.every((href) => {
235
+ return makeUrl(href).toJSON() === makeUrl(hrefs.at(0)).toJSON();
236
+ });
237
+ } catch {}
238
+ return false;
239
+ }
240
+ /**
241
+ * If the back-end did not specify a hash, if the navigation specified one,
242
+ * and both URLs lead to the same endpoint, we update the target URL
243
+ * to use the hash of the initially-requested URL.
244
+ */
220
245
  function fillHash(currentUrl, targetUrl) {
221
- currentUrl = makeUrl(currentUrl);
222
- targetUrl = makeUrl(targetUrl);
223
- if (currentUrl.hash && !targetUrl.hash && sameUrls(targetUrl, currentUrl)) {
224
- targetUrl.hash = currentUrl.hash;
225
- }
226
- return targetUrl.toString();
246
+ currentUrl = makeUrl(currentUrl);
247
+ targetUrl = makeUrl(targetUrl);
248
+ if (currentUrl.hash && !targetUrl.hash && sameUrls(targetUrl, currentUrl)) targetUrl.hash = currentUrl.hash;
249
+ return targetUrl.toString();
227
250
  }
228
251
 
252
+ //#endregion
253
+ //#region src/router/history.ts
254
+ /** Puts the given context into the history state. */
229
255
  function setHistoryState(options = {}) {
230
- if (!window?.history) {
231
- throw new Error("The history API is not available, so Hybridly cannot operate.");
232
- }
233
- const context = getRouterContext();
234
- const method = options.replace ? "replaceState" : "pushState";
235
- const serialized = serializeContext(context);
236
- debug.history("Setting history state:", {
237
- method,
238
- context
239
- // serialized,
240
- });
241
- try {
242
- window.history[method](serialized, "", context.url);
243
- } catch (error) {
244
- console.error("Hybridly could not save its current state in the history. This is most likely due to a property being non-serializable, such as a proxy or a reference.");
245
- throw error;
246
- }
247
- }
256
+ if (!window?.history) throw new Error("The history API is not available, so Hybridly cannot operate.");
257
+ const context = getRouterContext();
258
+ const method = options.replace ? "replaceState" : "pushState";
259
+ const serialized = serializeContext(context);
260
+ debug.history("Setting history state:", {
261
+ method,
262
+ context
263
+ });
264
+ try {
265
+ window.history[method](serialized, "", context.url);
266
+ } catch (error) {
267
+ console.error("Hybridly could not save its current state in the history. This is most likely due to a property being non-serializable, such as a proxy or a reference.");
268
+ throw error;
269
+ }
270
+ }
271
+ /** Gets the current history data if it exists. */
248
272
  function getHistoryState() {
249
- return getRouterContext().serializer.unserialize(window.history.state);
273
+ return getRouterContext().serializer.unserialize(window.history.state);
250
274
  }
275
+ /** Gets the current history state if it exists. */
251
276
  function getHistoryMemo(key) {
252
- const state = getHistoryState();
253
- return key ? state?.memo?.[key] : state?.memo;
277
+ const state$1 = getHistoryState();
278
+ return key ? state$1?.memo?.[key] : state$1?.memo;
254
279
  }
280
+ /** Register history-related event listeneners. */
255
281
  async function registerEventListeners() {
256
- const context = getRouterContext();
257
- debug.history("Registering [popstate] and [scroll] event listeners.");
258
- window?.addEventListener("popstate", async (event) => {
259
- debug.history("Navigation detected (popstate event). State:", { state: event.state });
260
- if (context.pendingNavigation) {
261
- debug.router("Aborting current navigation.", context.pendingNavigation);
262
- context.pendingNavigation?.controller?.abort();
263
- }
264
- const state = context.serializer.unserialize(event.state);
265
- await runHooks("backForward", {}, state, context);
266
- if (!state) {
267
- debug.history("There is no state. Adding hash if any and restoring scroll positions.");
268
- return await navigate({
269
- type: "initial",
270
- payload: {
271
- ...context,
272
- url: makeUrl(context.url, { hash: window.location.hash }).toString()
273
- },
274
- preserveScroll: true,
275
- preserveState: true,
276
- replace: true
277
- });
278
- }
279
- await navigate({
280
- type: "back-forward",
281
- payload: state,
282
- preserveScroll: true,
283
- preserveState: !!getInternalRouterContext().dialog || !!state.dialog,
284
- updateHistoryState: false
285
- });
286
- });
287
- window?.addEventListener("scroll", (event) => debounce(100, () => {
288
- if (event?.target?.hasAttribute?.(SCROLL_REGION_ATTRIBUTE)) {
289
- saveScrollPositions();
290
- }
291
- }), true);
292
- }
282
+ const context = getRouterContext();
283
+ debug.history("Registering [popstate] and [scroll] event listeners.");
284
+ window?.addEventListener("popstate", async (event) => {
285
+ debug.history("Navigation detected (popstate event). State:", { state: event.state });
286
+ if (context.pendingNavigation) {
287
+ debug.router("Aborting current navigation.", context.pendingNavigation);
288
+ context.pendingNavigation?.controller?.abort();
289
+ }
290
+ const state$1 = context.serializer.unserialize(event.state);
291
+ await runHooks("backForward", {}, state$1, context);
292
+ if (!state$1) {
293
+ debug.history("There is no state. Adding hash if any and restoring scroll positions.");
294
+ return await navigate({
295
+ type: "initial",
296
+ payload: {
297
+ ...context,
298
+ url: makeUrl(context.url, { hash: window.location.hash }).toString()
299
+ },
300
+ preserveScroll: true,
301
+ preserveState: true,
302
+ replace: true
303
+ });
304
+ }
305
+ await navigate({
306
+ type: "back-forward",
307
+ payload: state$1,
308
+ preserveScroll: true,
309
+ preserveState: !!getInternalRouterContext().dialog || !!state$1.dialog,
310
+ updateHistoryState: false
311
+ });
312
+ });
313
+ window?.addEventListener("scroll", (event) => debounce(100, () => {
314
+ if ((event?.target)?.hasAttribute?.(SCROLL_REGION_ATTRIBUTE)) saveScrollPositions();
315
+ }), true);
316
+ }
317
+ /** Checks if the current navigation was made by going back or forward. */
293
318
  function isBackForwardNavigation() {
294
- if (!window.history.state) {
295
- return false;
296
- }
297
- return window.performance?.getEntriesByType("navigation").at(0)?.type === "back_forward";
319
+ if (!window.history.state) return false;
320
+ return (window.performance?.getEntriesByType("navigation").at(0))?.type === "back_forward";
298
321
  }
322
+ /** Handles a navigation which was going back or forward. */
299
323
  async function handleBackForwardNavigation() {
300
- debug.router("Handling a back/forward navigation from an external URL.");
301
- const context = getRouterContext();
302
- const state = getHistoryState();
303
- if (!state) {
304
- throw new Error("Tried to handling a back/forward navigation, but there was no state in the history. This should not happen.");
305
- }
306
- await navigate({
307
- type: "back-forward",
308
- payload: {
309
- ...state,
310
- version: context.version
311
- },
312
- preserveScroll: true,
313
- preserveState: false,
314
- updateHistoryState: false
315
- });
316
- }
324
+ debug.router("Handling a back/forward navigation from an external URL.");
325
+ const context = getRouterContext();
326
+ const state$1 = getHistoryState();
327
+ if (!state$1) throw new Error("Tried to handling a back/forward navigation, but there was no state in the history. This should not happen.");
328
+ await navigate({
329
+ type: "back-forward",
330
+ payload: {
331
+ ...state$1,
332
+ version: context.version
333
+ },
334
+ preserveScroll: true,
335
+ preserveState: false,
336
+ updateHistoryState: false
337
+ });
338
+ }
339
+ /** Saves a value into the current history state. */
317
340
  function remember(key, value) {
318
- debug.history(`Remembering key "${key}" with value`, value);
319
- setContext({
320
- memo: {
321
- ...getHistoryMemo(),
322
- [key]: value
323
- }
324
- }, { propagate: false });
325
- setHistoryState({ replace: true });
326
- }
341
+ debug.history(`Remembering key "${key}" with value`, value);
342
+ setContext({ memo: {
343
+ ...getHistoryMemo(),
344
+ [key]: value
345
+ } }, { propagate: false });
346
+ setHistoryState({ replace: true });
347
+ }
348
+ /** Serializes the context so it can be written to the history state. */
327
349
  function serializeContext(context) {
328
- return context.serializer.serialize({
329
- url: context.url,
330
- version: context.version,
331
- view: context.view,
332
- dialog: context.dialog,
333
- scrollRegions: context.scrollRegions,
334
- memo: context.memo
335
- });
350
+ return context.serializer.serialize({
351
+ url: context.url,
352
+ version: context.version,
353
+ view: context.view,
354
+ dialog: context.dialog,
355
+ scrollRegions: context.scrollRegions,
356
+ memo: context.memo
357
+ });
336
358
  }
337
359
  function createSerializer(options) {
338
- if (options.serializer) {
339
- return options.serializer;
340
- }
341
- return {
342
- serialize: (data) => {
343
- debug.history("Serializing data.", data);
344
- return stringify(data);
345
- },
346
- unserialize: (data) => {
347
- if (!data) {
348
- debug.history("No data to unserialize.");
349
- return;
350
- }
351
- return parse(data);
352
- }
353
- };
360
+ if (options.serializer) return options.serializer;
361
+ return {
362
+ serialize: (data) => {
363
+ debug.history("Serializing data.", data);
364
+ return stringify(data);
365
+ },
366
+ unserialize: (data) => {
367
+ if (!data) {
368
+ debug.history("No data to unserialize.");
369
+ return;
370
+ }
371
+ return parse(data);
372
+ }
373
+ };
354
374
  }
355
375
 
376
+ //#endregion
377
+ //#region src/routing/route.ts
356
378
  function getUrlRegexForRoute(name) {
357
- const routing = getRouting();
358
- const definition = getRouteDefinition(name);
359
- const path = definition.uri.replaceAll("/", "\\/");
360
- const domain = definition.domain;
361
- const protocolPrefix = routing.url.match(/^\w+:\/\//)?.[0];
362
- const origin = domain ? `${protocolPrefix}${domain}${routing.port ? `:${routing.port}` : ""}`.replaceAll("/", "\\/") : routing.url.replaceAll("/", "\\/");
363
- const urlPathRegexPattern = path.length > 0 ? `\\/${path.replace(/\/$/g, "")}` : "";
364
- let urlRegexPattern = `^${origin.replaceAll(".", "\\.")}${urlPathRegexPattern}\\/?(\\?.*)?$`;
365
- urlRegexPattern = urlRegexPattern.replace(/(\\\/?){([^}?]+)(\??)}/g, (_, slash, parameterName, optional) => {
366
- const where = definition.wheres?.[parameterName];
367
- let regexTemplate = where?.replace(/(^\^)|(\$$)/g, "") || "[^/?]+";
368
- regexTemplate = `(?<${parameterName}>${regexTemplate})`;
369
- if (optional) {
370
- return `(${slash ? "\\/?" : ""}${regexTemplate})?`;
371
- }
372
- return (slash ? "\\/" : "") + regexTemplate;
373
- });
374
- return RegExp(urlRegexPattern);
375
- }
379
+ const routing = getRouting();
380
+ const definition = getRouteDefinition(name);
381
+ const path = definition.uri.replaceAll("/", "\\/");
382
+ const domain = definition.domain;
383
+ const protocolPrefix = routing.url.match(/^\w+:\/\//)?.[0];
384
+ const origin = domain ? `${protocolPrefix}${domain}${routing.port ? `:${routing.port}` : ""}`.replaceAll("/", "\\/") : routing.url.replaceAll("/", "\\/");
385
+ const urlPathRegexPattern = path.length > 0 ? `\\/${path.replace(/\/$/g, "")}` : "";
386
+ let urlRegexPattern = `^${origin.replaceAll(".", "\\.")}${urlPathRegexPattern}\\/?(\\?.*)?$`;
387
+ urlRegexPattern = urlRegexPattern.replace(/(\\\/?){([^}?]+)(\??)}/g, (_, slash, parameterName, optional) => {
388
+ let regexTemplate = (definition.wheres?.[parameterName])?.replace(/(^\^)|(\$$)/g, "") || "[^/?]+";
389
+ regexTemplate = `(?<${parameterName}>${regexTemplate})`;
390
+ if (optional) return `(${slash ? "\\/?" : ""}${regexTemplate})?`;
391
+ return (slash ? "\\/" : "") + regexTemplate;
392
+ });
393
+ return RegExp(urlRegexPattern);
394
+ }
395
+ /**
396
+ * Check if a given URL matches a route based on its name.
397
+ * Additionally you can pass an object of parameters to check if the URL matches the route with the given parameters.
398
+ * Otherwise it will accept and thus return true for any values for the parameters defined by the route.
399
+ * Note: passing additional parameters that are not defined by the route or included in the current URL will cause this to return false.
400
+ */
376
401
  function urlMatchesRoute(fullUrl, name, routeParameters) {
377
- const url = makeUrl(fullUrl, { hash: "" }).toString();
378
- const parameters = routeParameters || {};
379
- const definition = getRouting().routes[name];
380
- if (!definition) {
381
- return false;
382
- }
383
- const matches = getUrlRegexForRoute(name).exec(url);
384
- if (!matches) {
385
- return false;
386
- }
387
- for (const k in matches.groups) {
388
- matches.groups[k] = typeof matches.groups[k] === "string" ? decodeURIComponent(matches.groups[k]) : matches.groups[k];
389
- }
390
- return Object.keys(parameters).every((parameterName) => {
391
- let value = parameters[parameterName];
392
- const bindingProperty = definition.bindings?.[parameterName];
393
- if (bindingProperty && typeof value === "object") {
394
- value = value[bindingProperty];
395
- }
396
- return matches.groups?.[parameterName] === value.toString();
397
- });
402
+ const url = makeUrl(fullUrl, { hash: "" }).toString();
403
+ const parameters = routeParameters || {};
404
+ const definition = getRouting().routes[name];
405
+ if (!definition) return false;
406
+ const matches = getUrlRegexForRoute(name).exec(url);
407
+ if (!matches) return false;
408
+ for (const k in matches.groups) matches.groups[k] = typeof matches.groups[k] === "string" ? decodeURIComponent(matches.groups[k]) : matches.groups[k];
409
+ return Object.keys(parameters).every((parameterName) => {
410
+ let value = parameters[parameterName];
411
+ const bindingProperty = definition.bindings?.[parameterName];
412
+ if (bindingProperty && typeof value === "object") value = value[bindingProperty];
413
+ return matches.groups?.[parameterName] === value.toString();
414
+ });
398
415
  }
399
416
  function generateRouteFromName(name, parameters, absolute, shouldThrow) {
400
- const url = getUrlFromName(name, parameters);
401
- return absolute === false ? url.toString().replace(url.origin, "") : url.toString();
417
+ const url = getUrlFromName(name, parameters, shouldThrow);
418
+ return absolute === false ? url.toString().replace(url.origin, "") || "/" : url.toString();
402
419
  }
403
420
  function getNameFromUrl(url, parameters) {
404
- const routing = getRouting();
405
- const routes = Object.values(routing.routes).map((x) => x.name);
406
- return routes.find((routeName) => {
407
- return urlMatchesRoute(url, routeName, parameters);
408
- });
421
+ const routing = getRouting();
422
+ return Object.values(routing.routes).map((x) => x.name).find((routeName) => {
423
+ return urlMatchesRoute(url, routeName, parameters);
424
+ });
409
425
  }
410
426
  function getUrlFromName(name, parameters, shouldThrow) {
411
- const routing = getRouting();
412
- const definition = getRouteDefinition(name);
413
- const transforms = getRouteTransformable(name, parameters);
414
- const url = makeUrl(routing.url, (url2) => ({
415
- hostname: definition.domain || url2.hostname,
416
- port: routing.port?.toString() || url2.port,
417
- trailingSlash: false,
418
- ...transforms
419
- }));
420
- return url;
421
- }
427
+ const routing = getRouting();
428
+ const definition = getRouteDefinition(name);
429
+ const transforms = getRouteTransformable(name, parameters, shouldThrow);
430
+ return makeUrl(routing.url, (url) => ({
431
+ hostname: definition.domain || url.hostname,
432
+ port: routing.port?.toString() || url.port,
433
+ trailingSlash: false,
434
+ ...transforms
435
+ }));
436
+ }
437
+ /**
438
+ * Resolved the value of a route parameter from either the passed parameters or the default parameters.
439
+ */
422
440
  function getRouteParameterValue(routeName, parameterName, routeParameters) {
423
- const routing = getRouting();
424
- const definition = getRouteDefinition(routeName);
425
- const parameters = routeParameters || {};
426
- const value = (() => {
427
- const value2 = parameters[parameterName];
428
- const bindingProperty = definition.bindings?.[parameterName];
429
- if (bindingProperty && value2 != null && typeof value2 === "object") {
430
- return value2[bindingProperty];
431
- }
432
- return value2;
433
- })();
434
- if (value) {
435
- const where = definition.wheres?.[parameterName];
436
- if (where && !new RegExp(where).test(value)) {
437
- console.warn(`[hybridly:routing] Parameter [${parameterName}] does not match the required format [${where}] for route [${routeName}].`);
438
- }
439
- return value;
440
- }
441
- if (routing.defaults?.[parameterName]) {
442
- return routing.defaults?.[parameterName];
443
- }
444
- }
441
+ const routing = getRouting();
442
+ const definition = getRouteDefinition(routeName);
443
+ const parameters = routeParameters || {};
444
+ const value = (() => {
445
+ const value$1 = parameters[parameterName];
446
+ const bindingProperty = definition.bindings?.[parameterName];
447
+ if (bindingProperty && value$1 != null && typeof value$1 === "object") return value$1[bindingProperty];
448
+ return value$1;
449
+ })();
450
+ if (value) {
451
+ const where = definition.wheres?.[parameterName];
452
+ if (where && !new RegExp(where).test(value)) console.warn(`[hybridly:routing] Parameter [${parameterName}] does not match the required format [${where}] for route [${routeName}].`);
453
+ return value;
454
+ }
455
+ if (routing.defaults?.[parameterName]) return routing.defaults?.[parameterName];
456
+ }
457
+ /**
458
+ * Gets the `UrlTransformable` object for the given route and parameters.
459
+ */
445
460
  function getRouteTransformable(routeName, routeParameters, shouldThrow) {
446
- const definition = getRouteDefinition(routeName);
447
- const parameters = routeParameters || {};
448
- const missing = Object.keys(parameters);
449
- const replaceParameter = (match, parameterName, optional) => {
450
- const value = getRouteParameterValue(routeName, parameterName, parameters);
451
- const found = missing.indexOf(parameterName);
452
- if (found >= 0) {
453
- missing.splice(found, 1);
454
- }
455
- if (value) {
456
- return value;
457
- }
458
- if (optional) {
459
- return "";
460
- }
461
- throw new MissingRouteParameter(parameterName, routeName);
462
- };
463
- const path = definition.uri.replace(/{([^}?]+)(\??)}/g, replaceParameter);
464
- const domain = definition.domain?.replace(/{([^}?]+)(\??)}/g, replaceParameter);
465
- const remaining = Object.keys(parameters).filter((key) => missing.includes(key)).reduce((obj, key) => ({
466
- ...obj,
467
- [key]: parameters[key]
468
- }), {});
469
- return {
470
- ...domain && { hostname: domain },
471
- pathname: path,
472
- search: qs.stringify(remaining, {
473
- encodeValuesOnly: true,
474
- arrayFormat: "indices",
475
- addQueryPrefix: true
476
- })
477
- };
478
- }
461
+ const definition = getRouteDefinition(routeName);
462
+ const parameters = routeParameters || {};
463
+ const missing = Object.keys(parameters);
464
+ const replaceParameter = (match$1, parameterName, optional) => {
465
+ const value = getRouteParameterValue(routeName, parameterName, parameters);
466
+ const found = missing.indexOf(parameterName);
467
+ if (found >= 0) missing.splice(found, 1);
468
+ if (value) return value;
469
+ if (optional) return "";
470
+ if (shouldThrow === false) return "";
471
+ throw new MissingRouteParameter(parameterName, routeName);
472
+ };
473
+ const path = definition.uri.replace(/{([^}?]+)(\??)}/g, replaceParameter);
474
+ const domain = definition.domain?.replace(/{([^}?]+)(\??)}/g, replaceParameter);
475
+ const remaining = Object.keys(parameters).filter((key) => missing.includes(key)).reduce((obj, key) => ({
476
+ ...obj,
477
+ [key]: parameters[key]
478
+ }), {});
479
+ return {
480
+ ...domain && { hostname: domain },
481
+ pathname: path,
482
+ search: qs.stringify(remaining, {
483
+ encodeValuesOnly: true,
484
+ arrayFormat: "indices",
485
+ addQueryPrefix: true
486
+ })
487
+ };
488
+ }
489
+ /**
490
+ * Gets the route definition.
491
+ */
479
492
  function getRouteDefinition(name) {
480
- const routing = getRouting();
481
- const definition = routing.routes[name];
482
- if (!definition) {
483
- throw new RouteNotFound(name);
484
- }
485
- return definition;
493
+ const definition = getRouting().routes[name];
494
+ if (!definition) throw new RouteNotFound(name);
495
+ return definition;
486
496
  }
497
+ /**
498
+ * Gets the routing configuration from the current context.
499
+ */
487
500
  function getRouting() {
488
- const { routing } = getInternalRouterContext();
489
- if (!routing) {
490
- throw new RoutingNotInitialized();
491
- }
492
- return routing;
501
+ const { routing } = getInternalRouterContext();
502
+ if (!routing) throw new RoutingNotInitialized();
503
+ return routing;
493
504
  }
505
+ /**
506
+ * Generates a route from the given route name.
507
+ */
494
508
  function route(name, parameters, absolute) {
495
- return generateRouteFromName(name, parameters, absolute);
509
+ return generateRouteFromName(name, parameters, absolute ?? getRouting().absolute ?? true);
496
510
  }
497
511
 
512
+ //#endregion
513
+ //#region src/routing/current.ts
498
514
  function getCurrentUrl() {
499
- if (typeof window === "undefined") {
500
- return getInternalRouterContext().url;
501
- }
502
- return window.location.toString();
503
- }
515
+ if (typeof window === "undefined") return getInternalRouterContext().url;
516
+ return window.location.toString();
517
+ }
518
+ /**
519
+ * Determines whether the current route matches the given name and parameters.
520
+ * If multiple routes match, the first one will be returned.
521
+ *
522
+ * @example
523
+ * ```ts
524
+ * currentRouteMatches('tenant.*') // matches all routes starting with 'tenant.'
525
+ * currentRouteMatches('tenant.*.admin') // matches all routes starting with 'tenant.' and ending with '.admin'
526
+ * ```
527
+ */
504
528
  function currentRouteMatches(name, parameters) {
505
- const namePattern = `^${name.replaceAll(".", "\\.").replaceAll("*", ".*")}$`;
506
- const possibleRoutes = Object.values(getRouting().routes).filter((x) => x.method.includes("GET") && RegExp(namePattern).test(x.name)).map((x) => x.name);
507
- const currentUrl = getCurrentUrl();
508
- return possibleRoutes.some((routeName) => {
509
- return urlMatchesRoute(currentUrl, routeName, parameters);
510
- });
529
+ const namePattern = `^${name.replaceAll(".", "\\.").replaceAll("*", ".*")}$`;
530
+ const possibleRoutes = Object.values(getRouting().routes).filter((x) => x.method.includes("GET") && RegExp(namePattern).test(x.name)).map((x) => x.name);
531
+ const currentUrl = getCurrentUrl();
532
+ return possibleRoutes.some((routeName) => {
533
+ return urlMatchesRoute(currentUrl, routeName, parameters);
534
+ });
511
535
  }
512
536
  function getCurrentRouteName() {
513
- return getNameFromUrl(getCurrentUrl());
537
+ return getNameFromUrl(getCurrentUrl());
514
538
  }
515
539
 
540
+ //#endregion
541
+ //#region src/routing/index.ts
516
542
  function updateRoutingConfiguration(routing) {
517
- if (!routing) {
518
- return;
519
- }
520
- setContext({ routing });
543
+ if (!routing) return;
544
+ setContext({ routing });
521
545
  }
522
546
 
547
+ //#endregion
548
+ //#region src/download.ts
549
+ /** Checks if the response wants to redirect to an external URL. */
523
550
  function isDownloadResponse(response) {
524
- return response.status === 200 && !!response.headers["content-disposition"];
551
+ return response.status === 200 && !!response.headers["content-disposition"];
525
552
  }
553
+ /** Handles a download. */
526
554
  async function handleDownloadResponse(response) {
527
- const blob = new Blob([response.data], { type: response.headers["content-type"] });
528
- const urlObject = window.webkitURL || window.URL;
529
- const link = document.createElement("a");
530
- link.style.display = "none";
531
- link.href = urlObject.createObjectURL(blob);
532
- link.download = getFileNameFromContentDispositionHeader(response.headers["content-disposition"]);
533
- link.click();
534
- setTimeout(() => {
535
- urlObject.revokeObjectURL(link.href);
536
- link.remove();
537
- }, 0);
555
+ const blob = new Blob([response.data], { type: response.headers["content-type"] });
556
+ const urlObject = window.webkitURL || window.URL;
557
+ const link = document.createElement("a");
558
+ link.style.display = "none";
559
+ link.href = urlObject.createObjectURL(blob);
560
+ link.download = getFileNameFromContentDispositionHeader(response.headers["content-disposition"]);
561
+ link.click();
562
+ setTimeout(() => {
563
+ urlObject.revokeObjectURL(link.href);
564
+ link.remove();
565
+ }, 0);
538
566
  }
539
567
  function getFileNameFromContentDispositionHeader(header) {
540
- const result = header.split(";")[1]?.trim().split("=")[1];
541
- return result?.replace(/^"(.*)"$/, "$1") ?? "";
568
+ return (header.split(";")[1]?.trim().split("=")[1])?.replace(/^"(.*)"$/, "$1") ?? "";
542
569
  }
543
570
 
571
+ //#endregion
572
+ //#region src/context/context.ts
544
573
  const state = {
545
- initialized: false,
546
- context: {}
574
+ initialized: false,
575
+ context: {}
547
576
  };
577
+ /** Gets the current context. */
548
578
  function getRouterContext() {
549
- return getInternalRouterContext();
579
+ return getInternalRouterContext();
550
580
  }
581
+ /** Gets the current context, but not in read-only. */
551
582
  function getInternalRouterContext() {
552
- if (!state.initialized) {
553
- throw new Error("Hybridly is not initialized.");
554
- }
555
- return state.context;
583
+ if (!state.initialized) throw new Error("Hybridly is not initialized.");
584
+ return state.context;
556
585
  }
586
+ /** Initializes the context. */
557
587
  async function initializeContext(options) {
558
- state.initialized = true;
559
- state.context = {
560
- ...options.payload,
561
- responseErrorModals: options.responseErrorModals,
562
- serializer: createSerializer(options),
563
- url: makeUrl(options.payload.url).toString(),
564
- adapter: {
565
- ...options.adapter,
566
- updateRoutingConfiguration
567
- },
568
- scrollRegions: [],
569
- plugins: options.plugins ?? [],
570
- axios: registerAxios(options.axios ?? axios.create()),
571
- routing: options.routing,
572
- preloadCache: /* @__PURE__ */ new Map(),
573
- hooks: {},
574
- memo: {}
575
- };
576
- await runHooks("initialized", {}, state.context);
577
- return getInternalRouterContext();
578
- }
579
- function registerAxios(axios2) {
580
- axios2.interceptors.response.use(
581
- (response) => {
582
- if (!isDownloadResponse(response)) {
583
- const text = new TextDecoder().decode(response.data);
584
- try {
585
- response.data = JSON.parse(text);
586
- } catch {
587
- response.data = text;
588
- }
589
- }
590
- return response;
591
- },
592
- (error) => Promise.reject(error)
593
- );
594
- return axios2;
595
- }
596
- function setContext(merge = {}, options = {}) {
597
- Object.keys(merge).forEach((key) => {
598
- Reflect.set(state.context, key, merge[key]);
599
- });
600
- if (options.propagate !== false) {
601
- state.context.adapter.onContextUpdate?.(state.context);
602
- }
603
- debug.context("Updated context:", { context: state.context, added: merge });
604
- }
588
+ state.initialized = true;
589
+ state.context = {
590
+ ...options.payload,
591
+ responseErrorModals: options.responseErrorModals,
592
+ serializer: createSerializer(options),
593
+ url: makeUrl(options.payload.url).toString(),
594
+ adapter: {
595
+ ...options.adapter,
596
+ updateRoutingConfiguration
597
+ },
598
+ scrollRegions: [],
599
+ plugins: options.plugins ?? [],
600
+ axios: registerAxios(options.axios ?? axios.create()),
601
+ routing: options.routing,
602
+ preloadCache: /* @__PURE__ */ new Map(),
603
+ hooks: {},
604
+ memo: {}
605
+ };
606
+ await runHooks("initialized", {}, state.context);
607
+ return getInternalRouterContext();
608
+ }
609
+ /**
610
+ * Registers an interceptor that assumes `arraybuffer`
611
+ * responses and converts responses to JSON or text.
612
+ */
613
+ function registerAxios(axios$1) {
614
+ axios$1.interceptors.response.use((response) => {
615
+ if (!isDownloadResponse(response)) {
616
+ const text = new TextDecoder().decode(response.data);
617
+ try {
618
+ response.data = JSON.parse(text);
619
+ } catch {
620
+ response.data = text;
621
+ }
622
+ }
623
+ return response;
624
+ }, (error) => Promise.reject(error));
625
+ return axios$1;
626
+ }
627
+ /**
628
+ * Mutates properties at the top-level of the context.
629
+ */
630
+ function setContext(merge$1 = {}, options = {}) {
631
+ Object.keys(merge$1).forEach((key) => {
632
+ Reflect.set(state.context, key, merge$1[key]);
633
+ });
634
+ if (options.propagate !== false) state.context.adapter.onContextUpdate?.(state.context);
635
+ debug.context("Updated context:", {
636
+ context: state.context,
637
+ added: merge$1
638
+ });
639
+ }
640
+ /** Gets a payload from the current context. */
605
641
  function payloadFromContext() {
606
- return {
607
- url: getRouterContext().url,
608
- version: getRouterContext().version,
609
- view: getRouterContext().view,
610
- dialog: getRouterContext().dialog
611
- };
642
+ return {
643
+ url: getRouterContext().url,
644
+ version: getRouterContext().version,
645
+ view: getRouterContext().view,
646
+ dialog: getRouterContext().dialog
647
+ };
612
648
  }
613
649
 
650
+ //#endregion
651
+ //#region src/external.ts
652
+ /**
653
+ * Performs an external navigation by saving options to the storage and
654
+ * making a full page reload. Upon loading, the navigation options
655
+ * will be pulled and a hybrid navigation will be made.
656
+ */
614
657
  async function performExternalNavigation(options) {
615
- debug.external("Navigating to an external URL:", options);
616
- if (options.target === "new-tab") {
617
- const link = document.createElement("a");
618
- link.style.display = "none";
619
- link.target = "_blank";
620
- link.href = options.url;
621
- link.click();
622
- setTimeout(() => link.remove(), 0);
623
- return;
624
- }
625
- window.sessionStorage.setItem(STORAGE_EXTERNAL_KEY, JSON.stringify(options));
626
- window.location.href = options.url;
627
- if (sameUrls(window.location, options.url)) {
628
- debug.external("Manually reloading due to the external URL being the same.");
629
- window.location.reload();
630
- }
631
- }
658
+ debug.external("Navigating to an external URL:", options);
659
+ if (options.target === "new-tab") {
660
+ const link = document.createElement("a");
661
+ link.style.display = "none";
662
+ link.target = "_blank";
663
+ link.href = options.url;
664
+ link.click();
665
+ setTimeout(() => link.remove(), 0);
666
+ return;
667
+ }
668
+ window.sessionStorage.setItem(STORAGE_EXTERNAL_KEY, JSON.stringify(options));
669
+ window.location.href = options.url;
670
+ if (sameUrls(window.location, options.url)) {
671
+ debug.external("Manually reloading due to the external URL being the same.");
672
+ window.location.reload();
673
+ }
674
+ }
675
+ /** Navigates to the given URL without the hybrid protocol. */
632
676
  function navigateToExternalUrl(url, data) {
633
- document.location.href = makeUrl(url, {
634
- search: qs.stringify(data, {
635
- encodeValuesOnly: true,
636
- arrayFormat: "brackets"
637
- })
638
- }).toString();
677
+ document.location.href = makeUrl(url, { search: qs.stringify(data, {
678
+ encodeValuesOnly: true,
679
+ arrayFormat: "brackets"
680
+ }) }).toString();
639
681
  }
682
+ /** Checks if the response wants to redirect to an external URL. */
640
683
  function isExternalResponse(response) {
641
- return response?.status === 409 && !!response?.headers?.[EXTERNAL_NAVIGATION_HEADER];
684
+ return response?.status === 409 && !!response?.headers?.[EXTERNAL_NAVIGATION_HEADER];
642
685
  }
686
+ /**
687
+ * Performs the internal navigation when an external navigation to a hybrid view has been made.
688
+ * This method is meant to be called on router creation.
689
+ */
643
690
  async function handleExternalNavigation() {
644
- debug.external("Handling an external navigation.");
645
- const options = JSON.parse(window.sessionStorage.getItem(STORAGE_EXTERNAL_KEY) || "{}");
646
- window.sessionStorage.removeItem(STORAGE_EXTERNAL_KEY);
647
- debug.external("Options from the session storage:", options);
648
- setContext({
649
- url: makeUrl(getRouterContext().url, { hash: window.location.hash }).toString()
650
- });
651
- await navigate({
652
- type: "initial",
653
- preserveState: true,
654
- preserveScroll: options.preserveScroll
655
- });
656
- }
691
+ debug.external("Handling an external navigation.");
692
+ const options = JSON.parse(window.sessionStorage.getItem(STORAGE_EXTERNAL_KEY) || "{}");
693
+ window.sessionStorage.removeItem(STORAGE_EXTERNAL_KEY);
694
+ debug.external("Options from the session storage:", options);
695
+ setContext({ url: makeUrl(getRouterContext().url, { hash: window.location.hash }).toString() });
696
+ await navigate({
697
+ type: "initial",
698
+ preserveState: true,
699
+ preserveScroll: options.preserveScroll
700
+ });
701
+ }
702
+ /** Checks if the navigation being initialized points to an external location. */
657
703
  function isExternalNavigation() {
658
- try {
659
- return window.sessionStorage.getItem(STORAGE_EXTERNAL_KEY) !== null;
660
- } catch {
661
- }
662
- return false;
704
+ try {
705
+ return window.sessionStorage.getItem(STORAGE_EXTERNAL_KEY) !== null;
706
+ } catch {}
707
+ return false;
663
708
  }
664
709
 
710
+ //#endregion
711
+ //#region src/dialog/index.ts
712
+ /**
713
+ * Closes the dialog.
714
+ */
665
715
  async function closeDialog(options) {
666
- const context = getInternalRouterContext();
667
- const url = context.dialog?.redirectUrl ?? context.dialog?.baseUrl;
668
- if (!url) {
669
- return;
670
- }
671
- context.adapter.onDialogClose?.(context);
672
- if (options?.local === true) {
673
- return await performLocalNavigation(url, {
674
- preserveScroll: true,
675
- preserveState: true,
676
- dialog: false,
677
- component: context.view.component,
678
- properties: context.view.properties,
679
- ...options
680
- });
681
- }
682
- return await performHybridNavigation({
683
- url,
684
- preserveScroll: true,
685
- preserveState: true,
686
- ...options
687
- });
716
+ const context = getInternalRouterContext();
717
+ const url = context.dialog?.redirectUrl ?? context.dialog?.baseUrl;
718
+ if (!url) return;
719
+ context.adapter.onDialogClose?.(context);
720
+ if (options?.local === true) return await performLocalNavigation(url, {
721
+ preserveScroll: true,
722
+ preserveState: true,
723
+ dialog: false,
724
+ component: context.view.component,
725
+ properties: context.view.properties,
726
+ ...options
727
+ });
728
+ return await performHybridNavigation({
729
+ url,
730
+ preserveScroll: true,
731
+ preserveState: true,
732
+ ...options
733
+ });
688
734
  }
689
735
 
736
+ //#endregion
737
+ //#region src/router/preload.ts
738
+ /**
739
+ * Checks if there is a preloaded request for the given URL.
740
+ */
690
741
  function isPreloaded(targetUrl) {
691
- const context = getInternalRouterContext();
692
- return context.preloadCache.has(targetUrl.toString()) ?? false;
742
+ return getInternalRouterContext().preloadCache.has(targetUrl.toString()) ?? false;
693
743
  }
744
+ /**
745
+ * Gets the response of a preloaded request.
746
+ */
694
747
  function getPreloadedRequest(targetUrl) {
695
- const context = getInternalRouterContext();
696
- return context.preloadCache.get(targetUrl.toString());
748
+ return getInternalRouterContext().preloadCache.get(targetUrl.toString());
697
749
  }
750
+ /**
751
+ * Stores the response of a preloaded request.
752
+ */
698
753
  function storePreloadRequest(targetUrl, response) {
699
- const context = getInternalRouterContext();
700
- context.preloadCache.set(targetUrl.toString(), response);
754
+ getInternalRouterContext().preloadCache.set(targetUrl.toString(), response);
701
755
  }
756
+ /**
757
+ * Discards a preloaded request.
758
+ */
702
759
  function discardPreloadedRequest(targetUrl) {
703
- const context = getInternalRouterContext();
704
- return context.preloadCache.delete(targetUrl.toString());
760
+ return getInternalRouterContext().preloadCache.delete(targetUrl.toString());
705
761
  }
762
+ /** Preloads a hybrid request. */
706
763
  async function performPreloadRequest(options) {
707
- const context = getRouterContext();
708
- const url = makeUrl(options.url ?? context.url);
709
- if (isPreloaded(url)) {
710
- debug.router("This request is already preloaded.");
711
- return false;
712
- }
713
- if (context.pendingNavigation) {
714
- debug.router("A navigation is pending, preload aborted.");
715
- return false;
716
- }
717
- if (options.method !== "GET") {
718
- debug.router("Cannot preload non-GET requests.");
719
- return false;
720
- }
721
- debug.router(`Preloading response for [${url.toString()}]`);
722
- try {
723
- const response = await performHybridRequest(url, options);
724
- if (!isHybridResponse(response)) {
725
- debug.router("Preload result was invalid.");
726
- return false;
727
- }
728
- storePreloadRequest(url, response);
729
- return true;
730
- } catch (error) {
731
- debug.router("Preloading failed.");
732
- return false;
733
- }
764
+ const context = getRouterContext();
765
+ const url = makeUrl(options.url ?? context.url);
766
+ if (isPreloaded(url)) {
767
+ debug.router("This request is already preloaded.");
768
+ return false;
769
+ }
770
+ if (context.pendingNavigation) {
771
+ debug.router("A navigation is pending, preload aborted.");
772
+ return false;
773
+ }
774
+ if (options.method !== "GET") {
775
+ debug.router("Cannot preload non-GET requests.");
776
+ return false;
777
+ }
778
+ debug.router(`Preloading response for [${url.toString()}]`);
779
+ try {
780
+ const response = await performHybridRequest(url, options);
781
+ if (!isHybridResponse(response)) {
782
+ debug.router("Preload result was invalid.");
783
+ return false;
784
+ }
785
+ storePreloadRequest(url, response);
786
+ return true;
787
+ } catch (error) {
788
+ debug.router("Preloading failed.");
789
+ return false;
790
+ }
734
791
  }
735
792
 
793
+ //#endregion
794
+ //#region src/router/router.ts
795
+ /**
796
+ * The hybridly router.
797
+ * This is the core function that you can use to navigate in
798
+ * your application. Make sure the routes you call return a
799
+ * hybrid response, otherwise you need to call `external`.
800
+ *
801
+ * @example
802
+ * router.get('/posts/edit', { post })
803
+ */
736
804
  const router = {
737
- abort: async () => getRouterContext().pendingNavigation?.controller.abort(),
738
- active: () => !!getRouterContext().pendingNavigation,
739
- navigate: async (options) => await performHybridNavigation(options),
740
- reload: async (options) => await performHybridNavigation({ preserveScroll: true, preserveState: true, replace: true, ...options }),
741
- get: async (url, options = {}) => await performHybridNavigation({ ...options, url, method: "GET" }),
742
- post: async (url, options = {}) => await performHybridNavigation({ preserveState: true, ...options, url, method: "POST" }),
743
- put: async (url, options = {}) => await performHybridNavigation({ preserveState: true, ...options, url, method: "PUT" }),
744
- patch: async (url, options = {}) => await performHybridNavigation({ preserveState: true, ...options, url, method: "PATCH" }),
745
- delete: async (url, options = {}) => await performHybridNavigation({ preserveState: true, ...options, url, method: "DELETE" }),
746
- local: async (url, options = {}) => await performLocalNavigation(url, options),
747
- preload: async (url, options = {}) => await performPreloadRequest({ ...options, url, method: "GET" }),
748
- external: (url, data = {}) => navigateToExternalUrl(url, data),
749
- to: async (name, parameters, options) => {
750
- const url = generateRouteFromName(name, parameters);
751
- const method = getRouteDefinition(name).method.at(0);
752
- return await performHybridNavigation({ url, ...options, method });
753
- },
754
- matches: (name, parameters) => currentRouteMatches(name, parameters),
755
- current: () => getCurrentRouteName(),
756
- dialog: {
757
- close: (options) => closeDialog(options)
758
- },
759
- history: {
760
- get: (key) => getHistoryMemo(key),
761
- remember: (key, value) => remember(key, value)
762
- }
805
+ abort: async () => getRouterContext().pendingNavigation?.controller.abort(),
806
+ active: () => !!getRouterContext().pendingNavigation,
807
+ navigate: async (options) => await performHybridNavigation(options),
808
+ reload: async (options) => await performHybridNavigation({
809
+ preserveScroll: true,
810
+ preserveState: true,
811
+ replace: true,
812
+ ...options
813
+ }),
814
+ get: async (url, options = {}) => await performHybridNavigation({
815
+ ...options,
816
+ url,
817
+ method: "GET"
818
+ }),
819
+ post: async (url, options = {}) => await performHybridNavigation({
820
+ preserveState: true,
821
+ ...options,
822
+ url,
823
+ method: "POST"
824
+ }),
825
+ put: async (url, options = {}) => await performHybridNavigation({
826
+ preserveState: true,
827
+ ...options,
828
+ url,
829
+ method: "PUT"
830
+ }),
831
+ patch: async (url, options = {}) => await performHybridNavigation({
832
+ preserveState: true,
833
+ ...options,
834
+ url,
835
+ method: "PATCH"
836
+ }),
837
+ delete: async (url, options = {}) => await performHybridNavigation({
838
+ preserveState: true,
839
+ ...options,
840
+ url,
841
+ method: "DELETE"
842
+ }),
843
+ local: async (url, options = {}) => await performLocalNavigation(url, options),
844
+ preload: async (url, options = {}) => await performPreloadRequest({
845
+ ...options,
846
+ url,
847
+ method: "GET"
848
+ }),
849
+ external: (url, data = {}) => navigateToExternalUrl(url, data),
850
+ to: async (name, parameters, options) => {
851
+ const url = generateRouteFromName(name, parameters);
852
+ const method = getRouteDefinition(name).method.at(0);
853
+ return await performHybridNavigation({
854
+ url,
855
+ ...options,
856
+ method
857
+ });
858
+ },
859
+ matches: (name, parameters) => currentRouteMatches(name, parameters),
860
+ current: () => getCurrentRouteName(),
861
+ dialog: { close: (options) => closeDialog(options) },
862
+ history: {
863
+ get: (key) => getHistoryMemo(key),
864
+ remember: (key, value) => remember(key, value)
865
+ }
763
866
  };
867
+ /** Creates the hybridly router. */
764
868
  async function createRouter(options) {
765
- await initializeContext(options);
766
- return await initializeRouter();
869
+ await initializeContext(options);
870
+ return await initializeRouter();
767
871
  }
872
+ /** Performs every action necessary to make a hybrid navigation. */
768
873
  async function performHybridNavigation(options) {
769
- const navigationId = random();
770
- const context = getRouterContext();
771
- debug.router("Making a hybrid navigation:", { context, options, navigationId });
772
- try {
773
- if (!options.method) {
774
- debug.router("Setting method to GET because none was provided.");
775
- options.method = "GET";
776
- }
777
- options.method = options.method.toUpperCase();
778
- if ((hasFiles(options.data) || options.useFormData) && !(options.data instanceof FormData)) {
779
- options.data = objectToFormData(options.data);
780
- debug.router("Converted data to FormData.", options.data);
781
- }
782
- if (!(options.data instanceof FormData) && options.method === "GET" && Object.keys(options.data ?? {}).length) {
783
- debug.router("Transforming data to query parameters.", options.data);
784
- options.url = makeUrl(options.url ?? context.url, {
785
- query: options.data
786
- });
787
- options.data = {};
788
- }
789
- if (["PUT", "PATCH", "DELETE"].includes(options.method) && options.spoof !== false) {
790
- debug.router(`Automatically spoofing method ${options.method}.`);
791
- if (options.data instanceof FormData) {
792
- options.data.append("_method", options.method);
793
- } else if (typeof options.data === "undefined") {
794
- options.data = { _method: options.method };
795
- } else if (options.data instanceof Object && Object.keys(options.data).length >= 0) {
796
- Object.assign(options.data, { _method: options.method });
797
- } else {
798
- debug.router("Could not spoof method because body type is not supported.", options.data);
799
- }
800
- options.method = "POST";
801
- }
802
- if (!await runHooks("before", options.hooks, options, context)) {
803
- debug.router('"before" event returned false, aborting the navigation.');
804
- throw new NavigationCancelledError('The navigation was cancelled by the "before" event.');
805
- }
806
- if (context.pendingNavigation) {
807
- debug.router("Aborting current navigation.", context.pendingNavigation);
808
- context.pendingNavigation?.controller?.abort();
809
- }
810
- saveScrollPositions();
811
- const targetUrl = makeUrl(options.url ?? context.url, options.transformUrl);
812
- const abortController = new AbortController();
813
- setContext({
814
- pendingNavigation: {
815
- id: navigationId,
816
- url: targetUrl,
817
- controller: abortController,
818
- status: "pending",
819
- options
820
- }
821
- });
822
- await runHooks("start", options.hooks, context);
823
- debug.router("Making request with axios.");
824
- const response = await performHybridRequest(targetUrl, options, abortController);
825
- const result = await runHooks("data", options.hooks, response, context);
826
- if (result === false) {
827
- return { response };
828
- }
829
- if (isExternalResponse(response)) {
830
- debug.router("The response is explicitely external.");
831
- await performExternalNavigation({
832
- url: fillHash(targetUrl, response.headers[EXTERNAL_NAVIGATION_HEADER]),
833
- preserveScroll: options.preserveScroll === true,
834
- target: response.headers[EXTERNAL_NAVIGATION_TARGET_HEADER] ?? "current"
835
- });
836
- return { response };
837
- }
838
- if (isDownloadResponse(response)) {
839
- debug.router("The response returns a file to download.");
840
- await handleDownloadResponse(response);
841
- return { response };
842
- }
843
- if (!isHybridResponse(response)) {
844
- throw new NotAHybridResponseError(response);
845
- }
846
- debug.router("The response respects the Hybridly protocol.");
847
- const payload = response.data;
848
- if (payload.view && (options.only?.length ?? options.except?.length) && payload.view.component === context.view.component) {
849
- debug.router(`Merging ${options.only ? '"only"' : '"except"'} properties.`, payload.view.properties);
850
- const mergedPayloadProperties = merge(context.view.properties, payload.view.properties);
851
- if (options.errorBag) {
852
- mergedPayloadProperties.errors[options.errorBag] = payload.view.properties.errors[options.errorBag] ?? {};
853
- } else {
854
- mergedPayloadProperties.errors = payload.view.properties.errors;
855
- }
856
- payload.view.properties = mergedPayloadProperties;
857
- debug.router("Merged properties:", payload.view.properties);
858
- }
859
- await navigate({
860
- type: "server",
861
- payload: {
862
- ...payload,
863
- url: fillHash(targetUrl, payload.url)
864
- },
865
- preserveScroll: options.preserveScroll,
866
- preserveState: options.preserveState,
867
- preserveUrl: options.preserveUrl,
868
- replace: options.replace === true || options.preserveUrl || sameUrls(payload.url, window.location.href) && !sameHashes(payload.url, window.location.href)
869
- });
870
- if (Object.keys(context.view.properties.errors ?? {}).length > 0) {
871
- const errors = (() => {
872
- if (options.errorBag && typeof context.view.properties.errors === "object") {
873
- return context.view.properties.errors[options.errorBag] ?? {};
874
- }
875
- return context.view.properties.errors;
876
- })();
877
- debug.router("The request returned validation errors.", errors);
878
- setContext({
879
- pendingNavigation: {
880
- ...context.pendingNavigation,
881
- status: "error"
882
- }
883
- });
884
- await runHooks("error", options.hooks, errors, context);
885
- } else {
886
- setContext({
887
- pendingNavigation: {
888
- ...context.pendingNavigation,
889
- status: "success"
890
- }
891
- });
892
- await runHooks("success", options.hooks, payload, context);
893
- }
894
- return { response };
895
- } catch (error) {
896
- await match(error.constructor.name, {
897
- NavigationCancelledError: async () => {
898
- debug.router('The request was cancelled through the "before" hook.', error);
899
- await runHooks("abort", options.hooks, context);
900
- },
901
- AbortError: async () => {
902
- debug.router("The request was aborted.", error);
903
- await runHooks("abort", options.hooks, context);
904
- },
905
- NotAHybridResponseError: async () => {
906
- debug.router("The response was not hybrid.");
907
- console.error(error);
908
- await runHooks("invalid", options.hooks, error, context);
909
- if (context.responseErrorModals) {
910
- showResponseErrorModal(error.response.data);
911
- }
912
- },
913
- default: async () => {
914
- if (error?.name === "CanceledError") {
915
- debug.router("The request was cancelled.", error);
916
- await runHooks("abort", options.hooks, context);
917
- } else {
918
- debug.router("An unknown error occured.", error);
919
- console.error(error);
920
- await runHooks("exception", options.hooks, error, context);
921
- }
922
- }
923
- });
924
- await runHooks("fail", options.hooks, context);
925
- return {
926
- error: {
927
- type: error.constructor.name,
928
- actual: error
929
- }
930
- };
931
- } finally {
932
- debug.router("Ending navigation.");
933
- await runHooks("after", options.hooks, context);
934
- if (context.pendingNavigation?.id === navigationId) {
935
- setContext({ pendingNavigation: void 0 });
936
- }
937
- }
938
- }
874
+ const navigationId = random();
875
+ const context = getRouterContext();
876
+ debug.router("Making a hybrid navigation:", {
877
+ context,
878
+ options,
879
+ navigationId
880
+ });
881
+ try {
882
+ if (!options.method) {
883
+ debug.router("Setting method to GET because none was provided.");
884
+ options.method = "GET";
885
+ }
886
+ options.method = options.method.toUpperCase();
887
+ if ((hasFiles(options.data) || options.useFormData) && !(options.data instanceof FormData)) {
888
+ options.data = objectToFormData(options.data);
889
+ debug.router("Converted data to FormData.", options.data);
890
+ }
891
+ if (!(options.data instanceof FormData) && options.method === "GET" && Object.keys(options.data ?? {}).length) {
892
+ debug.router("Transforming data to query parameters.", options.data);
893
+ options.url = makeUrl(options.url ?? context.url, { query: options.data });
894
+ options.data = {};
895
+ }
896
+ if ([
897
+ "PUT",
898
+ "PATCH",
899
+ "DELETE"
900
+ ].includes(options.method) && options.spoof !== false) {
901
+ debug.router(`Automatically spoofing method ${options.method}.`);
902
+ if (options.data instanceof FormData) options.data.append("_method", options.method);
903
+ else if (typeof options.data === "undefined") options.data = { _method: options.method };
904
+ else if (options.data instanceof Object && Object.keys(options.data).length >= 0) Object.assign(options.data, { _method: options.method });
905
+ else debug.router("Could not spoof method because body type is not supported.", options.data);
906
+ options.method = "POST";
907
+ }
908
+ if (!await runHooks("before", options.hooks, options, context)) {
909
+ debug.router("\"before\" event returned false, aborting the navigation.");
910
+ throw new NavigationCancelledError("The navigation was cancelled by the \"before\" event.");
911
+ }
912
+ if (context.pendingNavigation) {
913
+ debug.router("Aborting current navigation.", context.pendingNavigation);
914
+ context.pendingNavigation?.controller?.abort();
915
+ }
916
+ saveScrollPositions();
917
+ const targetUrl = makeUrl(options.url ?? context.url, options.transformUrl);
918
+ const abortController = new AbortController();
919
+ setContext({ pendingNavigation: {
920
+ id: navigationId,
921
+ url: targetUrl,
922
+ controller: abortController,
923
+ status: "pending",
924
+ options
925
+ } });
926
+ await runHooks("start", options.hooks, context);
927
+ debug.router("Making request with axios.");
928
+ const response = await performHybridRequest(targetUrl, options, abortController);
929
+ if (await runHooks("data", options.hooks, response, context) === false) return { response };
930
+ if (isExternalResponse(response)) {
931
+ debug.router("The response is explicitely external.");
932
+ await performExternalNavigation({
933
+ url: fillHash(targetUrl, response.headers[EXTERNAL_NAVIGATION_HEADER]),
934
+ preserveScroll: options.preserveScroll === true,
935
+ target: response.headers[EXTERNAL_NAVIGATION_TARGET_HEADER] ?? "current"
936
+ });
937
+ return { response };
938
+ }
939
+ if (isDownloadResponse(response)) {
940
+ debug.router("The response returns a file to download.");
941
+ await handleDownloadResponse(response);
942
+ return { response };
943
+ }
944
+ if (!isHybridResponse(response)) throw new NotAHybridResponseError(response);
945
+ debug.router("The response respects the Hybridly protocol.");
946
+ const payload = response.data;
947
+ if (payload.view && (options.only?.length ?? options.except?.length) && payload.view.component === context.view.component) {
948
+ debug.router(`Merging ${options.only ? "\"only\"" : "\"except\""} properties.`, payload.view.properties);
949
+ const mergedPayloadProperties = merge(context.view.properties, payload.view.properties);
950
+ if (options.errorBag) mergedPayloadProperties.errors[options.errorBag] = payload.view.properties.errors[options.errorBag] ?? {};
951
+ else mergedPayloadProperties.errors = payload.view.properties.errors;
952
+ payload.view.properties = mergedPayloadProperties;
953
+ debug.router("Merged properties:", payload.view.properties);
954
+ }
955
+ await navigate({
956
+ type: "server",
957
+ payload: {
958
+ ...payload,
959
+ url: fillHash(targetUrl, payload.url)
960
+ },
961
+ preserveScroll: options.preserveScroll,
962
+ preserveState: options.preserveState,
963
+ preserveUrl: options.preserveUrl,
964
+ replace: options.replace === true || options.preserveUrl || sameUrls(payload.url, window.location.href) && !sameHashes(payload.url, window.location.href)
965
+ });
966
+ if (Object.keys(context.view.properties.errors ?? {}).length > 0) {
967
+ const errors = (() => {
968
+ if (options.errorBag && typeof context.view.properties.errors === "object") return context.view.properties.errors[options.errorBag] ?? {};
969
+ return context.view.properties.errors;
970
+ })();
971
+ debug.router("The request returned validation errors.", errors);
972
+ setContext({ pendingNavigation: {
973
+ ...context.pendingNavigation,
974
+ status: "error"
975
+ } });
976
+ await runHooks("error", options.hooks, errors, context);
977
+ } else {
978
+ setContext({ pendingNavigation: {
979
+ ...context.pendingNavigation,
980
+ status: "success"
981
+ } });
982
+ await runHooks("success", options.hooks, payload, context);
983
+ }
984
+ return { response };
985
+ } catch (error) {
986
+ await match(error.constructor.name, {
987
+ NavigationCancelledError: async () => {
988
+ debug.router("The request was cancelled through the \"before\" hook.", error);
989
+ await runHooks("abort", options.hooks, context);
990
+ },
991
+ AbortError: async () => {
992
+ debug.router("The request was aborted.", error);
993
+ await runHooks("abort", options.hooks, context);
994
+ },
995
+ NotAHybridResponseError: async () => {
996
+ debug.router("The response was not hybrid.");
997
+ console.error(error);
998
+ await runHooks("invalid", options.hooks, error, context);
999
+ if (context.responseErrorModals) showResponseErrorModal(error.response.data);
1000
+ },
1001
+ default: async () => {
1002
+ if (error?.name === "CanceledError") {
1003
+ debug.router("The request was cancelled.", error);
1004
+ await runHooks("abort", options.hooks, context);
1005
+ } else {
1006
+ debug.router("An unknown error occured.", error);
1007
+ console.error(error);
1008
+ await runHooks("exception", options.hooks, error, context);
1009
+ }
1010
+ }
1011
+ });
1012
+ await runHooks("fail", options.hooks, context);
1013
+ return { error: {
1014
+ type: error.constructor.name,
1015
+ actual: error
1016
+ } };
1017
+ } finally {
1018
+ debug.router("Ending navigation.");
1019
+ await runHooks("after", options.hooks, context);
1020
+ if (context.pendingNavigation?.id === navigationId) setContext({ pendingNavigation: void 0 });
1021
+ }
1022
+ }
1023
+ /** Checks if the response contains a hybrid header. */
939
1024
  function isHybridResponse(response) {
940
- return !!response?.headers[HYBRIDLY_HEADER];
1025
+ return !!response?.headers[HYBRIDLY_HEADER];
941
1026
  }
1027
+ /**
1028
+ * Makes an internal navigation that swaps the view and updates the context.
1029
+ * @internal
1030
+ */
942
1031
  async function navigate(options) {
943
- const context = getRouterContext();
944
- options.hasDialog ??= !!options.payload?.dialog;
945
- debug.router("Making an internal navigation:", { context, options });
946
- await runHooks("navigating", {}, options, context);
947
- options.payload ??= payloadFromContext();
948
- options.payload.view ??= payloadFromContext().view;
949
- function evaluateConditionalOption(option) {
950
- return typeof option === "function" ? option(options) : option;
951
- }
952
- const shouldPreserveState = evaluateConditionalOption(options.preserveState);
953
- const shouldPreserveScroll = evaluateConditionalOption(options.preserveScroll);
954
- const shouldReplaceHistory = evaluateConditionalOption(options.replace);
955
- const shouldReplaceUrl = evaluateConditionalOption(options.preserveUrl);
956
- const shouldPreserveView = !options.payload.view.component;
957
- if (shouldPreserveState && getHistoryMemo() && options.payload.view.component === context.view.component) {
958
- debug.history("Setting the memo from this history entry into the current context.");
959
- setContext({ memo: getHistoryMemo() });
960
- }
961
- if (shouldReplaceUrl) {
962
- debug.router(`Preserving the current URL (${context.url}) instead of navigating to ${options.payload.url}`);
963
- options.payload.url = context.url;
964
- }
965
- const payload = shouldPreserveView ? {
966
- view: {
967
- component: context.view.component,
968
- properties: merge(context.view.properties, options.payload.view.properties),
969
- deferred: context.view.deferred
970
- },
971
- url: context.url,
972
- version: options.payload.version,
973
- dialog: context.dialog
974
- } : options.payload;
975
- setContext({ ...payload, memo: {} });
976
- if (options.updateHistoryState !== false) {
977
- debug.router(`Target URL is ${context.url}, current window URL is ${window.location.href}.`, { shouldReplaceHistory });
978
- setHistoryState({ replace: shouldReplaceHistory });
979
- }
980
- if (context.view.deferred?.length) {
981
- debug.router("Request has deferred properties, queueing a partial reload:", context.view.deferred);
982
- context.adapter.executeOnMounted(async () => {
983
- await performHybridNavigation({
984
- preserveScroll: true,
985
- preserveState: true,
986
- replace: true,
987
- only: context.view.deferred
988
- });
989
- });
990
- }
991
- const viewComponent = !shouldPreserveView ? await context.adapter.resolveComponent(context.view.component) : void 0;
992
- if (viewComponent) {
993
- debug.router(`Component [${context.view.component}] resolved to:`, viewComponent);
994
- }
995
- await context.adapter.onViewSwap({
996
- component: viewComponent,
997
- dialog: context.dialog,
998
- properties: options.payload?.view?.properties,
999
- preserveState: shouldPreserveState,
1000
- onMounted: (hookOptions) => runHooks("mounted", {}, { ...options, ...hookOptions }, context)
1001
- });
1002
- if (options.type === "back-forward") {
1003
- restoreScrollPositions();
1004
- } else if (!shouldPreserveScroll) {
1005
- resetScrollPositions();
1006
- }
1007
- await runHooks("navigated", {}, options, context);
1032
+ const context = getRouterContext();
1033
+ options.hasDialog ??= !!options.payload?.dialog;
1034
+ debug.router("Making an internal navigation:", {
1035
+ context,
1036
+ options
1037
+ });
1038
+ await runHooks("navigating", {}, options, context);
1039
+ options.payload ??= payloadFromContext();
1040
+ options.payload.view ??= payloadFromContext().view;
1041
+ function evaluateConditionalOption(option) {
1042
+ return typeof option === "function" ? option(options) : option;
1043
+ }
1044
+ const shouldPreserveState = evaluateConditionalOption(options.preserveState);
1045
+ const shouldPreserveScroll = evaluateConditionalOption(options.preserveScroll);
1046
+ const shouldReplaceHistory = evaluateConditionalOption(options.replace);
1047
+ const shouldReplaceUrl = evaluateConditionalOption(options.preserveUrl);
1048
+ const shouldPreserveView = !options.payload.view.component;
1049
+ if (shouldPreserveState && getHistoryMemo() && options.payload.view.component === context.view.component) {
1050
+ debug.history("Setting the memo from this history entry into the current context.");
1051
+ setContext({ memo: getHistoryMemo() });
1052
+ }
1053
+ if (shouldReplaceUrl) {
1054
+ debug.router(`Preserving the current URL (${context.url}) instead of navigating to ${options.payload.url}`);
1055
+ options.payload.url = context.url;
1056
+ }
1057
+ setContext({
1058
+ ...shouldPreserveView ? {
1059
+ view: {
1060
+ component: context.view.component,
1061
+ properties: merge(context.view.properties, options.payload.view.properties),
1062
+ deferred: context.view.deferred
1063
+ },
1064
+ url: context.url,
1065
+ version: options.payload.version,
1066
+ dialog: context.dialog
1067
+ } : options.payload,
1068
+ memo: {}
1069
+ });
1070
+ if (options.updateHistoryState !== false) {
1071
+ debug.router(`Target URL is ${context.url}, current window URL is ${window.location.href}.`, { shouldReplaceHistory });
1072
+ setHistoryState({ replace: shouldReplaceHistory });
1073
+ }
1074
+ if (context.view.deferred?.length) {
1075
+ debug.router("Request has deferred properties, queueing a partial reload:", context.view.deferred);
1076
+ context.adapter.executeOnMounted(async () => {
1077
+ await performHybridNavigation({
1078
+ preserveScroll: true,
1079
+ preserveState: true,
1080
+ replace: true,
1081
+ only: context.view.deferred
1082
+ });
1083
+ });
1084
+ }
1085
+ const viewComponent = !shouldPreserveView ? await context.adapter.resolveComponent(context.view.component) : void 0;
1086
+ if (viewComponent) debug.router(`Component [${context.view.component}] resolved to:`, viewComponent);
1087
+ await context.adapter.onViewSwap({
1088
+ component: viewComponent,
1089
+ dialog: context.dialog,
1090
+ properties: options.payload?.view?.properties,
1091
+ preserveState: shouldPreserveState,
1092
+ onMounted: (hookOptions) => runHooks("mounted", {}, {
1093
+ ...options,
1094
+ ...hookOptions
1095
+ }, context)
1096
+ });
1097
+ if (options.type === "back-forward") restoreScrollPositions();
1098
+ else if (!shouldPreserveScroll) resetScrollPositions();
1099
+ await runHooks("navigated", {}, options, context);
1008
1100
  }
1009
1101
  async function performHybridRequest(targetUrl, options, abortController) {
1010
- const context = getInternalRouterContext();
1011
- const preloaded = options.method === "GET" ? getPreloadedRequest(targetUrl) : false;
1012
- if (preloaded) {
1013
- debug.router(`Found a pre-loaded request for [${targetUrl}]`);
1014
- discardPreloadedRequest(targetUrl);
1015
- return preloaded;
1016
- }
1017
- return await context.axios.request({
1018
- url: targetUrl.toString(),
1019
- method: options.method,
1020
- data: options.method === "GET" ? {} : options.data,
1021
- params: options.method === "GET" ? options.data : {},
1022
- signal: abortController?.signal,
1023
- headers: {
1024
- ...options.headers,
1025
- ...context.dialog ? { [DIALOG_KEY_HEADER]: context.dialog.key } : {},
1026
- ...context.dialog ? { [DIALOG_REDIRECT_HEADER]: context.dialog.redirectUrl ?? "" } : {},
1027
- ...when(options.only !== void 0 || options.except !== void 0, {
1028
- [PARTIAL_COMPONENT_HEADER]: context.view.component,
1029
- ...when(options.only, { [ONLY_DATA_HEADER]: JSON.stringify(options.only) }, {}),
1030
- ...when(options.except, { [EXCEPT_DATA_HEADER]: JSON.stringify(options.except) }, {})
1031
- }, {}),
1032
- ...when(options.errorBag, { [ERROR_BAG_HEADER]: options.errorBag }, {}),
1033
- ...when(context.version, { [VERSION_HEADER]: context.version }, {}),
1034
- [HYBRIDLY_HEADER]: true,
1035
- "X-Requested-With": "XMLHttpRequest",
1036
- "Accept": "text/html, application/xhtml+xml"
1037
- },
1038
- responseType: "arraybuffer",
1039
- validateStatus: () => true,
1040
- onUploadProgress: async (event) => {
1041
- await runHooks("progress", options.hooks, {
1042
- event,
1043
- percentage: Math.round(event.loaded / (event.total ?? 0) * 100)
1044
- }, context);
1045
- }
1046
- });
1047
- }
1102
+ const context = getInternalRouterContext();
1103
+ const preloaded = options.method === "GET" ? getPreloadedRequest(targetUrl) : false;
1104
+ if (preloaded) {
1105
+ debug.router(`Found a pre-loaded request for [${targetUrl}]`);
1106
+ discardPreloadedRequest(targetUrl);
1107
+ return preloaded;
1108
+ }
1109
+ return await context.axios.request({
1110
+ url: targetUrl.toString(),
1111
+ method: options.method,
1112
+ data: options.method === "GET" ? {} : options.data,
1113
+ params: options.method === "GET" ? options.data : {},
1114
+ signal: abortController?.signal,
1115
+ headers: {
1116
+ ...options.headers,
1117
+ ...context.dialog ? { [DIALOG_KEY_HEADER]: context.dialog.key } : {},
1118
+ ...context.dialog ? { [DIALOG_REDIRECT_HEADER]: context.dialog.redirectUrl ?? "" } : {},
1119
+ ...when(options.only !== void 0 || options.except !== void 0, {
1120
+ [PARTIAL_COMPONENT_HEADER]: context.view.component,
1121
+ ...when(options.only, { [ONLY_DATA_HEADER]: JSON.stringify(options.only) }, {}),
1122
+ ...when(options.except, { [EXCEPT_DATA_HEADER]: JSON.stringify(options.except) }, {})
1123
+ }, {}),
1124
+ ...when(options.errorBag, { [ERROR_BAG_HEADER]: options.errorBag }, {}),
1125
+ ...when(context.version, { [VERSION_HEADER]: context.version }, {}),
1126
+ [HYBRIDLY_HEADER]: true,
1127
+ "X-Requested-With": "XMLHttpRequest",
1128
+ "Accept": "text/html, application/xhtml+xml"
1129
+ },
1130
+ responseType: "arraybuffer",
1131
+ validateStatus: () => true,
1132
+ onUploadProgress: async (event) => {
1133
+ await runHooks("progress", options.hooks, {
1134
+ event,
1135
+ percentage: Math.round(event.loaded / (event.total ?? 0) * 100)
1136
+ }, context);
1137
+ }
1138
+ });
1139
+ }
1140
+ /** Initializes the router by reading the context and registering events if necessary. */
1048
1141
  async function initializeRouter() {
1049
- const context = getRouterContext();
1050
- if (isBackForwardNavigation()) {
1051
- handleBackForwardNavigation();
1052
- } else if (isExternalNavigation()) {
1053
- handleExternalNavigation();
1054
- } else {
1055
- debug.router("Handling the initial navigation.");
1056
- setContext({
1057
- url: makeUrl(context.url, { hash: window.location.hash }).toString()
1058
- });
1059
- await navigate({
1060
- type: "initial",
1061
- preserveState: true,
1062
- replace: sameUrls(context.url, window.location.href)
1063
- });
1064
- }
1065
- registerEventListeners();
1066
- await runHooks("ready", {}, context);
1067
- return context;
1068
- }
1142
+ const context = getRouterContext();
1143
+ if (isBackForwardNavigation()) handleBackForwardNavigation();
1144
+ else if (isExternalNavigation()) handleExternalNavigation();
1145
+ else {
1146
+ debug.router("Handling the initial navigation.");
1147
+ setContext({ url: makeUrl(context.url, { hash: window.location.hash }).toString() });
1148
+ await navigate({
1149
+ type: "initial",
1150
+ preserveState: true,
1151
+ replace: sameUrls(context.url, window.location.href)
1152
+ });
1153
+ }
1154
+ registerEventListeners();
1155
+ await runHooks("ready", {}, context);
1156
+ return context;
1157
+ }
1158
+ /** Performs a local navigation to the given component without a round-trip. */
1069
1159
  async function performLocalNavigation(targetUrl, options) {
1070
- const context = getRouterContext();
1071
- const url = normalizeUrl(targetUrl);
1072
- return await navigate({
1073
- ...options,
1074
- type: "local",
1075
- payload: {
1076
- version: context.version,
1077
- dialog: options?.dialog === false ? void 0 : options?.dialog ?? context.dialog,
1078
- url,
1079
- view: {
1080
- component: options?.component ?? context.view.component,
1081
- properties: options?.properties ?? context.view.properties,
1082
- deferred: []
1083
- }
1084
- }
1085
- });
1160
+ const context = getRouterContext();
1161
+ const url = normalizeUrl(targetUrl);
1162
+ return await navigate({
1163
+ ...options,
1164
+ type: "local",
1165
+ payload: {
1166
+ version: context.version,
1167
+ dialog: options?.dialog === false ? void 0 : options?.dialog ?? context.dialog,
1168
+ url,
1169
+ view: {
1170
+ component: options?.component ?? context.view.component,
1171
+ properties: options?.properties ?? context.view.properties,
1172
+ deferred: []
1173
+ }
1174
+ }
1175
+ });
1086
1176
  }
1087
1177
 
1178
+ //#endregion
1179
+ //#region src/authorization.ts
1180
+ /**
1181
+ * Checks whether the given data has the authorization for the given action.
1182
+ * If the data object has no authorization definition corresponding to the given action, this method will return `false`.
1183
+ */
1088
1184
  function can(resource, action) {
1089
- return resource.authorization?.[action] ?? false;
1185
+ return resource.authorization?.[action] ?? false;
1090
1186
  }
1091
1187
 
1092
- export { can, constants, createRouter, definePlugin, getRouterContext, makeUrl, registerHook, route, router, sameUrls };
1188
+ //#endregion
1189
+ export { can, constants_exports as constants, createRouter, definePlugin, getRouterContext, makeUrl, registerHook, route, router, sameUrls };