@dharmax/state-router 3.2.0 → 4.0.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.
@@ -2,19 +2,30 @@ import { router } from "./router";
2
2
  import dispatcher from "@dharmax/pubsub";
3
3
  export class StateManager {
4
4
  mode;
5
- allStates = {};
6
- appState;
7
- previousState;
8
- stateContext;
5
+ #allStates = {};
6
+ #appState = null;
7
+ #previousState = null;
8
+ #stateContext;
9
+ #currentTransitionId = 0;
9
10
  static dispatcher = dispatcher;
10
- changeAuthorities = [];
11
- constructor(mode = 'hash', autostart = true) {
11
+ #changeAuthorities = [];
12
+ #router;
13
+ #beforeChangeHandlers = [];
14
+ #afterChangeHandlers = [];
15
+ constructor(mode = 'hash', autostart = true, routerInstance = router) {
12
16
  this.mode = mode;
17
+ this.#router = routerInstance;
18
+ this.#router.setMode(mode);
13
19
  if (autostart)
14
- router.listen(mode);
20
+ this.#router.listen(mode);
15
21
  }
16
22
  start() {
17
- router.listen(this.mode);
23
+ this.#router.listen(this.mode);
24
+ }
25
+ stop() {
26
+ // unlisten router to cleanup event listeners
27
+ // @ts-ignore - older Router versions may not have unlisten
28
+ this.#router.unlisten && this.#router.unlisten();
18
29
  }
19
30
  onChange(handler) {
20
31
  return StateManager.dispatcher.on('state:changed', handler);
@@ -25,16 +36,16 @@ export class StateManager {
25
36
  requested doesn't happen.
26
37
  **/
27
38
  registerChangeAuthority(authorityCallback) {
28
- this.changeAuthorities.push(authorityCallback);
39
+ this.#changeAuthorities.push(authorityCallback);
29
40
  }
30
41
  getState() {
31
- return this.appState || {};
42
+ return this.#appState || {};
32
43
  }
33
44
  get previous() {
34
- return this.previousState;
45
+ return this.#previousState;
35
46
  }
36
47
  get context() {
37
- return this.stateContext;
48
+ return this.#stateContext;
38
49
  }
39
50
  /**
40
51
  * set current page state
@@ -43,16 +54,21 @@ export class StateManager {
43
54
  set state(state) {
44
55
  if (Array.isArray(state)) {
45
56
  const sName = state.shift();
46
- this.setState(sName, state);
57
+ this.setState(sName, state.length === 1 ? state[0] : state);
47
58
  }
48
59
  else
49
60
  this.setState(state);
50
61
  }
51
- /** attempts to restore state from current url. Currently, works only in hash mode */
62
+ /** attempts to restore state from current url. */
52
63
  restoreState(defaultState) {
53
- if (router.navigate(window.location.pathname))
64
+ if (this.#router.handleChange())
54
65
  return;
55
- router.navigate(defaultState);
66
+ const state = this.#allStates[defaultState];
67
+ let path = defaultState;
68
+ if (state && typeof state.route === 'string') {
69
+ path = state.route;
70
+ }
71
+ this.#router.navigate(path);
56
72
  }
57
73
  /**
58
74
  *
@@ -60,20 +76,34 @@ export class StateManager {
60
76
  * @param context extra context (e.g. sub-state)
61
77
  */
62
78
  async setState(stateName, context) {
63
- const newState = this.allStates[stateName];
79
+ const transitionId = ++this.#currentTransitionId;
80
+ const newState = this.#allStates[stateName];
64
81
  if (!newState) {
65
- alert(`Undefined app state ${stateName}`);
66
- return false;
82
+ throw new Error(`Undefined app state ${stateName}`);
67
83
  }
68
84
  // check if the state change was declined by any change authority and if so - don't do it and return false
69
- const changeConfirmations = await Promise.all(this.changeAuthorities.map(authority => authority(newState)));
70
- if (changeConfirmations.includes(false))
85
+ const changeConfirmations = await Promise.all(this.#changeAuthorities.map(authority => authority(newState)));
86
+ if (transitionId !== this.#currentTransitionId)
87
+ return false;
88
+ const vetoes = await Promise.all(this.#beforeChangeHandlers.map(h => Promise.resolve(h(newState, context))));
89
+ if (transitionId !== this.#currentTransitionId)
71
90
  return false;
91
+ if (changeConfirmations.includes(false) || vetoes.includes(false))
92
+ return false;
93
+ if (this.#appState && this.#appState.onExit) {
94
+ await this.#appState.onExit(this.#stateContext);
95
+ if (transitionId !== this.#currentTransitionId)
96
+ return false;
97
+ }
72
98
  // perform the change
73
- this.previousState = this.appState;
74
- this.stateContext = context;
75
- this.appState = newState;
76
- dispatcher.trigger('state-manager', 'state', 'changed', this.appState);
99
+ this.#previousState = this.#appState;
100
+ this.#stateContext = context;
101
+ this.#appState = newState;
102
+ dispatcher.trigger('state-manager', 'state', 'changed', this.#appState);
103
+ // afterChange hooks
104
+ for (const cb of this.#afterChangeHandlers) {
105
+ await cb(this.#appState, context, this.#previousState);
106
+ }
77
107
  return true;
78
108
  }
79
109
  /**
@@ -86,8 +116,9 @@ export class StateManager {
86
116
  addState(name, pageName, route, mode) {
87
117
  pageName = pageName || name;
88
118
  route = route || pageName;
89
- if (typeof route === "string") {
90
- let newRoute = route.split('%').join('?(.*)');
119
+ if (typeof route === 'string' && route.includes('%')) {
120
+ // keep colon-based string patterns intact for Router to compile named params
121
+ const newRoute = route.split('%').join('?(.*)');
91
122
  route = new RegExp(`^${newRoute}$`);
92
123
  }
93
124
  this.registerStateByState({
@@ -98,9 +129,14 @@ export class StateManager {
98
129
  });
99
130
  }
100
131
  registerStateByState(state) {
101
- this.allStates[state.name] = state;
102
- router.add(state.route, async (context) => {
103
- if (await this.setState(state.name, context)) {
132
+ this.#allStates[state.name] = state;
133
+ const self = this;
134
+ this.#router.add(state.route, async function (...captures) {
135
+ // prefer named params (from router string patterns), else multi-captures array, else first capture
136
+ // @ts-ignore
137
+ const named = (this && this.params) || null;
138
+ const context = named && Object.keys(named).length ? named : (captures.length <= 1 ? captures[0] : captures);
139
+ if (await self.setState(state.name, context)) {
104
140
  // @ts-ignore
105
141
  window.pageChangeHandler && window.pageChangeHandler('send', 'pageview', `/${state.name}/${context || ''}`);
106
142
  // @ts-ignore
@@ -108,4 +144,24 @@ export class StateManager {
108
144
  }
109
145
  });
110
146
  }
147
+ onBeforeChange(handler) {
148
+ this.#beforeChangeHandlers.push(handler);
149
+ }
150
+ onAfterChange(handler) {
151
+ this.#afterChangeHandlers.push(handler);
152
+ }
153
+ onExit(stateName, handler) {
154
+ const state = this.#allStates[stateName];
155
+ if (state) {
156
+ state.onExit = handler;
157
+ }
158
+ }
159
+ onNotFound(handler) {
160
+ // proxy to router-level notFound
161
+ // @ts-ignore - older Router versions may not have onNotFound
162
+ this.#router.onNotFound && this.#router.onNotFound(handler);
163
+ }
164
+ }
165
+ export function createStateManager(mode = 'hash', autostart = true, routerInstance = router) {
166
+ return new StateManager(mode, autostart, routerInstance);
111
167
  }
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./router"), exports);
18
+ __exportStar(require("./state-manager"), exports);
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.router = exports.Router = void 0;
4
+ exports.createRouter = createRouter;
5
+ class Router {
6
+ #mode = 'hash';
7
+ #routes = [];
8
+ #root = '/';
9
+ #rootCompare = '';
10
+ #baseLocation = null;
11
+ staticFilters = [];
12
+ #isListening = false;
13
+ #bound = {};
14
+ #notFoundHandler;
15
+ #decodeParams = false;
16
+ constructor() {
17
+ this.staticFilters.push(url => {
18
+ const staticFileExtensions = ['.json', '.css', '.js', '.png', '.jpg', '.svg', '.webp', '.md', '.ejs', '.jsm', '.txt'];
19
+ return staticFileExtensions.some(ext => url.endsWith(ext));
20
+ });
21
+ }
22
+ #cleanPathString(path) {
23
+ path = path.replace(/\/$/, '').replace(/^\//, '');
24
+ return path = path.replace(/#{2,}/g, '#');
25
+ }
26
+ #clearQuery(url) {
27
+ const [path, query] = url.split('?');
28
+ if (!query)
29
+ return path;
30
+ const [_, hash] = query.split('#');
31
+ return hash ? `${path}#${hash}` : path;
32
+ }
33
+ #isStaticFile(url) {
34
+ return this.staticFilters.some(filter => filter(url));
35
+ }
36
+ resetRoot(root) {
37
+ const cleaned = this.#cleanPathString(root);
38
+ this.#root = '/' + cleaned + '/';
39
+ this.#rootCompare = cleaned ? cleaned + '/' : '';
40
+ }
41
+ setMode(mode) {
42
+ this.#mode = mode;
43
+ }
44
+ getLocation() {
45
+ if (!this.#isBrowser())
46
+ return '';
47
+ if (this.#mode === 'history') {
48
+ let fragment = decodeURI(window.location.pathname + window.location.search);
49
+ fragment = this.#clearQuery(fragment);
50
+ // strip leading slash for comparison convenience
51
+ fragment = fragment.replace(/^\//, '');
52
+ if (this.#root !== '/' && this.#rootCompare && fragment.startsWith(this.#rootCompare)) {
53
+ fragment = fragment.slice(this.#rootCompare.length);
54
+ }
55
+ fragment = this.#cleanPathString(fragment);
56
+ return fragment;
57
+ }
58
+ else {
59
+ const match = window.location.href.match(/#(.*)$/);
60
+ return match ? this.#clearQuery(match[1]) : '';
61
+ }
62
+ }
63
+ add(pattern, handler) {
64
+ let paramNames;
65
+ if (typeof pattern === 'function') {
66
+ handler = pattern;
67
+ pattern = /^.*$/; // Match any path
68
+ }
69
+ else if (typeof pattern === 'string') {
70
+ const compiled = this.#compilePattern(pattern);
71
+ pattern = compiled.regex;
72
+ paramNames = compiled.paramNames;
73
+ }
74
+ this.#routes.push({ pattern: pattern, handler: handler, paramNames });
75
+ return this;
76
+ }
77
+ onNotFound(handler) {
78
+ this.#notFoundHandler = handler;
79
+ return this;
80
+ }
81
+ setDecodeParams(decode) {
82
+ this.#decodeParams = decode;
83
+ return this;
84
+ }
85
+ getQueryParams(search) {
86
+ if (!this.#isBrowser() && !search)
87
+ return {};
88
+ const qs = typeof search === 'string' ? search : window.location.search || '';
89
+ const usp = new URLSearchParams(qs);
90
+ const obj = {};
91
+ usp.forEach((v, k) => { obj[k] = v; });
92
+ return obj;
93
+ }
94
+ #isBrowser() {
95
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
96
+ }
97
+ #compilePattern(pattern) {
98
+ // normalize leading slash to align with cleanPathString behavior
99
+ const normalized = pattern.replace(/^\//, '');
100
+ const paramNames = [];
101
+ // convert /users/:id -> ^users/([^/]+)$
102
+ const reStr = normalized
103
+ .replace(/([.*+?^${}()|[\]\\])/g, '\\$1') // escape regex specials
104
+ .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_m, p1) => {
105
+ paramNames.push(p1);
106
+ return '([^/]+)';
107
+ });
108
+ return { regex: new RegExp(`^${reStr}$`), paramNames };
109
+ }
110
+ /**
111
+ *
112
+ * @param location
113
+ * @return true if it was intercepted or false if not handled
114
+ */
115
+ handleChange(location) {
116
+ const path = (location ?? this.getLocation()) || '';
117
+ if (this.#isStaticFile(path))
118
+ return false; // Bypass routing for static files
119
+ for (const route of this.#routes) {
120
+ const match = route.pattern ? path.match(route.pattern) : null;
121
+ if (match) {
122
+ match.shift(); // Remove the full match element
123
+ const queryParams = this.getQueryParams();
124
+ const captures = this.#decodeParams ? match.map(v => safeDecode(v)) : match;
125
+ let params;
126
+ if (route.paramNames && route.paramNames.length) {
127
+ params = {};
128
+ route.paramNames.forEach((n, i) => params[n] = captures[i]);
129
+ }
130
+ route.handler.call({ queryParams, params }, ...captures);
131
+ return true;
132
+ }
133
+ }
134
+ if (this.#notFoundHandler) {
135
+ this.#notFoundHandler(path);
136
+ return true;
137
+ }
138
+ if (path)
139
+ console.warn(`No routing found for ${path}`);
140
+ return false;
141
+ }
142
+ listen(mode = 'hash') {
143
+ if (!this.#isBrowser())
144
+ return;
145
+ // avoid duplicate listeners
146
+ if (this.#isListening)
147
+ this.unlisten();
148
+ const self = this;
149
+ this.#mode = mode;
150
+ const handler = (path) => {
151
+ const p = path || location.href.split('#')[0];
152
+ if (self.#isStaticFile(p))
153
+ return;
154
+ const currentLocation = self.getLocation();
155
+ if (self.#baseLocation !== currentLocation) {
156
+ self.#baseLocation = currentLocation;
157
+ self.handleChange(currentLocation);
158
+ }
159
+ };
160
+ const handleInternalNavigation = (event) => {
161
+ // modified clicks or non-left clicks
162
+ const me = event;
163
+ if (event.type === 'click') {
164
+ if (me.button !== 0)
165
+ return; // left-click only
166
+ if (me.metaKey || me.ctrlKey || me.shiftKey)
167
+ return;
168
+ }
169
+ const target = event.target;
170
+ if (!target)
171
+ return;
172
+ const anchor = target.closest('a');
173
+ if (!anchor)
174
+ return;
175
+ if (event.type === 'keydown') {
176
+ const ke = event;
177
+ if (ke.key !== 'Enter')
178
+ return;
179
+ }
180
+ const href = anchor.getAttribute('href') || '';
181
+ if (!href)
182
+ return;
183
+ const url = new URL(href, window.location.href);
184
+ // ignore external origins or different hostnames
185
+ if (url.origin !== window.location.origin)
186
+ return;
187
+ // ignore target=_blank, download, or rel=noreferrer
188
+ const t = anchor.getAttribute('target');
189
+ if (t && t.toLowerCase() === '_blank')
190
+ return;
191
+ if (anchor.hasAttribute('download'))
192
+ return;
193
+ const rel = anchor.getAttribute('rel');
194
+ if (rel && /\bnoreferrer\b/i.test(rel))
195
+ return;
196
+ const pathWithQuery = url.pathname + (url.search || '');
197
+ if (self.#isStaticFile(pathWithQuery) || self.#isStaticFile(url.pathname))
198
+ return;
199
+ event.preventDefault();
200
+ history.pushState({ path: pathWithQuery }, '', pathWithQuery);
201
+ handler(pathWithQuery);
202
+ };
203
+ switch (mode) {
204
+ case 'hash':
205
+ this.#bound.hashchange = () => handler();
206
+ window.addEventListener('hashchange', this.#bound.hashchange);
207
+ break;
208
+ case 'history':
209
+ this.#bound.popstate = (event) => handler(this.getLocation());
210
+ this.#bound.click = handleInternalNavigation;
211
+ this.#bound.keydown = (event) => handleInternalNavigation(event);
212
+ window.addEventListener('popstate', this.#bound.popstate);
213
+ document.addEventListener('click', this.#bound.click);
214
+ document.addEventListener('keydown', this.#bound.keydown);
215
+ break;
216
+ }
217
+ this.#isListening = true;
218
+ handler();
219
+ }
220
+ unlisten() {
221
+ if (!this.#isBrowser() || !this.#isListening)
222
+ return;
223
+ switch (this.#mode) {
224
+ case 'hash':
225
+ if (this.#bound.hashchange)
226
+ window.removeEventListener('hashchange', this.#bound.hashchange);
227
+ break;
228
+ case 'history':
229
+ if (this.#bound.popstate)
230
+ window.removeEventListener('popstate', this.#bound.popstate);
231
+ if (this.#bound.click)
232
+ document.removeEventListener('click', this.#bound.click);
233
+ if (this.#bound.keydown)
234
+ document.removeEventListener('keydown', this.#bound.keydown);
235
+ break;
236
+ }
237
+ this.#bound = {};
238
+ this.#isListening = false;
239
+ }
240
+ navigate(path = '', opts) {
241
+ if (!this.#isBrowser())
242
+ return false;
243
+ if (this.#mode === 'history') {
244
+ const url = this.#root + this.#cleanPathString(path);
245
+ if (opts?.replace)
246
+ history.replaceState(null, '', url);
247
+ else
248
+ history.pushState(null, '', url);
249
+ }
250
+ else
251
+ window.location.hash = this.#cleanPathString(path);
252
+ return this.handleChange();
253
+ }
254
+ replace(path = '') {
255
+ return this.navigate(path, { replace: true });
256
+ }
257
+ }
258
+ exports.Router = Router;
259
+ exports.router = new Router();
260
+ function createRouter() { return new Router(); }
261
+ function safeDecode(v) {
262
+ try {
263
+ return decodeURIComponent(v);
264
+ }
265
+ catch {
266
+ return v;
267
+ }
268
+ }
@@ -0,0 +1,29 @@
1
+ type RouteHandler = (...args: string[]) => void;
2
+ export type RoutingMode = 'history' | 'hash';
3
+ export declare class Router {
4
+ #private;
5
+ staticFilters: ((url: string) => boolean)[];
6
+ constructor();
7
+ resetRoot(root: string): void;
8
+ setMode(mode: RoutingMode): void;
9
+ getLocation(): string;
10
+ add(pattern: RegExp | string | RouteHandler, handler?: RouteHandler): Router;
11
+ onNotFound(handler: (path: string) => void): Router;
12
+ setDecodeParams(decode: boolean): Router;
13
+ getQueryParams(search?: string): Record<string, string>;
14
+ /**
15
+ *
16
+ * @param location
17
+ * @return true if it was intercepted or false if not handled
18
+ */
19
+ handleChange(location?: string): boolean;
20
+ listen(mode?: RoutingMode): void;
21
+ unlisten(): void;
22
+ navigate(path?: string, opts?: {
23
+ replace?: boolean;
24
+ }): boolean;
25
+ replace(path?: string): boolean;
26
+ }
27
+ export declare const router: Router;
28
+ export declare function createRouter(): Router;
29
+ export {};
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.StateManager = void 0;
4
+ exports.createStateManager = createStateManager;
5
+ const router_1 = require("./router");
6
+ const pubsub_1 = require("@dharmax/pubsub");
7
+ class StateManager {
8
+ mode;
9
+ #allStates = {};
10
+ #appState = null;
11
+ #previousState = null;
12
+ #stateContext;
13
+ #currentTransitionId = 0;
14
+ static dispatcher = pubsub_1.default;
15
+ #changeAuthorities = [];
16
+ #router;
17
+ #beforeChangeHandlers = [];
18
+ #afterChangeHandlers = [];
19
+ constructor(mode = 'hash', autostart = true, routerInstance = router_1.router) {
20
+ this.mode = mode;
21
+ this.#router = routerInstance;
22
+ this.#router.setMode(mode);
23
+ if (autostart)
24
+ this.#router.listen(mode);
25
+ }
26
+ start() {
27
+ this.#router.listen(this.mode);
28
+ }
29
+ stop() {
30
+ // unlisten router to cleanup event listeners
31
+ // @ts-ignore - older Router versions may not have unlisten
32
+ this.#router.unlisten && this.#router.unlisten();
33
+ }
34
+ onChange(handler) {
35
+ return StateManager.dispatcher.on('state:changed', handler);
36
+ }
37
+ /*
38
+ Add a hook which enable conditional approval of state change. It can be more than one; when a state
39
+ change is requested, all the registered authorities must return true (asynchronously) otherwise the change
40
+ requested doesn't happen.
41
+ **/
42
+ registerChangeAuthority(authorityCallback) {
43
+ this.#changeAuthorities.push(authorityCallback);
44
+ }
45
+ getState() {
46
+ return this.#appState || {};
47
+ }
48
+ get previous() {
49
+ return this.#previousState;
50
+ }
51
+ get context() {
52
+ return this.#stateContext;
53
+ }
54
+ /**
55
+ * set current page state
56
+ * @param state can be either just a state or a state and context (which can be sub-state, or anything else)
57
+ */
58
+ set state(state) {
59
+ if (Array.isArray(state)) {
60
+ const sName = state.shift();
61
+ this.setState(sName, state.length === 1 ? state[0] : state);
62
+ }
63
+ else
64
+ this.setState(state);
65
+ }
66
+ /** attempts to restore state from current url. */
67
+ restoreState(defaultState) {
68
+ if (this.#router.handleChange())
69
+ return;
70
+ const state = this.#allStates[defaultState];
71
+ let path = defaultState;
72
+ if (state && typeof state.route === 'string') {
73
+ path = state.route;
74
+ }
75
+ this.#router.navigate(path);
76
+ }
77
+ /**
78
+ *
79
+ * @param stateName state
80
+ * @param context extra context (e.g. sub-state)
81
+ */
82
+ async setState(stateName, context) {
83
+ const transitionId = ++this.#currentTransitionId;
84
+ const newState = this.#allStates[stateName];
85
+ if (!newState) {
86
+ throw new Error(`Undefined app state ${stateName}`);
87
+ }
88
+ // check if the state change was declined by any change authority and if so - don't do it and return false
89
+ const changeConfirmations = await Promise.all(this.#changeAuthorities.map(authority => authority(newState)));
90
+ if (transitionId !== this.#currentTransitionId)
91
+ return false;
92
+ const vetoes = await Promise.all(this.#beforeChangeHandlers.map(h => Promise.resolve(h(newState, context))));
93
+ if (transitionId !== this.#currentTransitionId)
94
+ return false;
95
+ if (changeConfirmations.includes(false) || vetoes.includes(false))
96
+ return false;
97
+ if (this.#appState && this.#appState.onExit) {
98
+ await this.#appState.onExit(this.#stateContext);
99
+ if (transitionId !== this.#currentTransitionId)
100
+ return false;
101
+ }
102
+ // perform the change
103
+ this.#previousState = this.#appState;
104
+ this.#stateContext = context;
105
+ this.#appState = newState;
106
+ pubsub_1.default.trigger('state-manager', 'state', 'changed', this.#appState);
107
+ // afterChange hooks
108
+ for (const cb of this.#afterChangeHandlers) {
109
+ await cb(this.#appState, context, this.#previousState);
110
+ }
111
+ return true;
112
+ }
113
+ /**
114
+ * Define an application state
115
+ * @param name
116
+ * @param pageName by default it equals the name (you can null it)
117
+ * @param route by default it equals the pageName (ditto)
118
+ * @param mode optional
119
+ */
120
+ addState(name, pageName, route, mode) {
121
+ pageName = pageName || name;
122
+ route = route || pageName;
123
+ if (typeof route === 'string' && route.includes('%')) {
124
+ // keep colon-based string patterns intact for Router to compile named params
125
+ const newRoute = route.split('%').join('?(.*)');
126
+ route = new RegExp(`^${newRoute}$`);
127
+ }
128
+ this.registerStateByState({
129
+ name,
130
+ pageName,
131
+ route,
132
+ mode
133
+ });
134
+ }
135
+ registerStateByState(state) {
136
+ this.#allStates[state.name] = state;
137
+ const self = this;
138
+ this.#router.add(state.route, async function (...captures) {
139
+ // prefer named params (from router string patterns), else multi-captures array, else first capture
140
+ // @ts-ignore
141
+ const named = (this && this.params) || null;
142
+ const context = named && Object.keys(named).length ? named : (captures.length <= 1 ? captures[0] : captures);
143
+ if (await self.setState(state.name, context)) {
144
+ // @ts-ignore
145
+ window.pageChangeHandler && window.pageChangeHandler('send', 'pageview', `/${state.name}/${context || ''}`);
146
+ // @ts-ignore
147
+ window.ga && window.ga('send', 'pageview', `/${state.name}/${context || ''}`);
148
+ }
149
+ });
150
+ }
151
+ onBeforeChange(handler) {
152
+ this.#beforeChangeHandlers.push(handler);
153
+ }
154
+ onAfterChange(handler) {
155
+ this.#afterChangeHandlers.push(handler);
156
+ }
157
+ onExit(stateName, handler) {
158
+ const state = this.#allStates[stateName];
159
+ if (state) {
160
+ state.onExit = handler;
161
+ }
162
+ }
163
+ onNotFound(handler) {
164
+ // proxy to router-level notFound
165
+ // @ts-ignore - older Router versions may not have onNotFound
166
+ this.#router.onNotFound && this.#router.onNotFound(handler);
167
+ }
168
+ }
169
+ exports.StateManager = StateManager;
170
+ function createStateManager(mode = 'hash', autostart = true, routerInstance = router_1.router) {
171
+ return new StateManager(mode, autostart, routerInstance);
172
+ }