white-label-router 1.1.2 → 4.0.0

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/README.md CHANGED
@@ -1,361 +1,246 @@
1
1
  # white-label-router
2
2
 
3
- A simple ES6 JS router based on the history api and pushstate.
3
+ `white-label-router` is a small client-side router built on the browser History API. It maps URL prefixes to functions or view lifecycle objects, intercepts links marked with `data-pushstate`, handles browser back/forward navigation, and can receive navigation requests through a mediator.
4
4
 
5
- Learn more about ES6 classes here:
5
+ ## Requirements
6
6
 
7
- https://babeljs.io/docs/learn-es2015/
7
+ - Node.js `^22.18.0` or `>=24.11.0` for installation and development
8
+ - A browser environment with `window.history`, `window.location`, and standard DOM events at runtime
9
+ - Server fallback configuration that serves the application entry page for client-managed URLs
8
10
 
9
- ## Install
11
+ ## Install and import
10
12
 
11
- Install the node module:
12
-
13
- ```
14
- npm install white-label-router --save
13
+ ```sh
14
+ npm install white-label-router
15
15
  ```
16
16
 
17
- ## Import
18
-
19
- ```
17
+ ```js
20
18
  import Router from 'white-label-router';
21
19
  ```
22
20
 
21
+ ## Complete example
23
22
 
24
- ## Defining Routes
25
-
26
- The most simple way simply executes any function you define:
23
+ ```js
24
+ import Router from 'white-label-router';
27
25
 
28
- ```
29
- const MyRoute = class extends Router {
26
+ class ApplicationRouter extends Router {
30
27
  constructor() {
31
28
  super();
32
- this.routes = {
33
- defaultRoute: (scope, locationData) => {
34
- //here you would put any view specific logic for the defaultRoute
35
- window.console.log('the defaultRoute executed');
36
- },
37
- '/page2': (scope, locationData) => {
38
- //here you would put any view specific logic for the page2 route
39
- window.console.log('the page2 route executed');
40
- }
41
- };
42
- }
43
- };
44
- ```
45
29
 
46
- or you can also define initialize and destroy functions in your view which will be automatically called as the user navigates your application:
47
-
48
- ```
49
- const MyRoute = class extends Router {
50
- constructor() {
51
- super();
30
+ this.scope = document.querySelector('main');
52
31
  this.routes = {
53
- defaultRoute: {
54
- view: {
55
- initialize: (scope, locationData) => {
56
- window.console.log('the defaultRoute initialized');
57
- },
58
- destroy: (scope, locationData) => {
59
- window.console.log('the defaultRoute has been destroyed');
60
- }
61
- }
32
+ '/products': (scope, location) => {
33
+ scope.textContent = `Product: ${location.data.url[0] || 'all'}`;
62
34
  },
63
- '/page2': {
64
- view: {
65
- initialize: (scope, locationData) => {
66
- window.console.log('the page2 initialized');
67
- },
68
- destroy: (scope, locationData) => {
69
- window.console.log('the page2 has been destroyed');
70
- }
71
- }
72
- }
73
- };
74
- }
75
- };
76
- ```
77
-
78
- You have some additional options when using the view object approach. They include the ability to set the page title for a route and also do security check before a route's view is initialized at the router level:
79
-
80
- ```
81
- const MyRoute = class extends Router {
82
- constructor() {
83
- super();
84
- this.routes = {
85
- defaultRoute: {
86
- title: 'Home',
35
+ '/account': {
36
+ title: 'Your account',
37
+ secure: () => Boolean(window.currentUser),
87
38
  view: {
88
- initialize: (scope, locationData) => {
89
- window.console.log('the defaultRoute initialized');
39
+ initialize: (scope, location) => {
40
+ scope.textContent = 'Account';
41
+ console.log(location.data.query);
90
42
  },
91
- destroy: (scope, locationData) => {
92
- window.console.log('the defaultRoute has been destroyed');
43
+ destroy: () => {
44
+ console.log('Leaving account');
93
45
  }
94
46
  }
95
47
  },
96
- '/page2': {
97
- title: 'Page 2',
98
- view: {
99
- initialize: (scope, locationData) => {
100
- window.console.log('the page2 initialized');
101
- },
102
- destroy: (scope, locationData) => {
103
- window.console.log('the page2 has been destroyed');
104
- }
105
- },
106
- secure: this.isSecure
48
+ defaultRoute: () => {
49
+ console.log('No configured route matched.');
107
50
  }
108
51
  };
109
52
  }
110
- isSecure (scope, locationData) => {
111
- if (someCookieIsValid) {
112
- return true;
113
- } else {
114
- return false;
115
- }
116
- }
117
- };
53
+ }
54
+
55
+ const router = new ApplicationRouter();
56
+ router.initialize();
118
57
  ```
119
58
 
120
- NOTE: routes are matched by checking if the beginning of the url path name string matches a route. For the example above the route '/page2' would be executed for both path names '/page2' and '/page23/232'.
59
+ At startup, `initialize()` reads the current path and query string, registers document click and browser `popstate` listeners, and navigates to the matching route.
121
60
 
122
- ## Scope
61
+ ## Define routes
123
62
 
124
- Optionally you and provide a DOM element to be the scope of the router. That DOM object will then be passed into all your routes as they are executed. If you do not provide a scope then it will be passed in as null into your views. To do so you would like this:
63
+ A route may be a function:
125
64
 
65
+ ```js
66
+ this.routes = {
67
+ '/help': (scope, location) => {
68
+ scope.textContent = `Help page: ${location.url}`;
69
+ }
70
+ };
126
71
  ```
127
- const MyRoute = class extends Router {
128
- constructor() {
129
- super();
130
72
 
131
- // the container element where all views will be placed
132
- this.scope = document.querySelector('body');
73
+ Or it may describe a title, security check, and view lifecycle:
133
74
 
134
- this.routes = {
135
- defaultRoute: (scope, locationData) => {
136
- //here you would put any view specific logic for the defaultRoute
137
- window.console.log('the defaultRoute executed');
75
+ ```js
76
+ this.routes = {
77
+ '/orders': {
78
+ title: 'Orders',
79
+ secure(scope, location) {
80
+ return Boolean(window.currentUser);
81
+ },
82
+ view: {
83
+ initialize(scope, location) {
84
+ scope.textContent = 'Order history';
138
85
  },
139
- '/page2': (scope, locationData) => {
140
- //here you would put any view specific logic for the page2 route
141
- window.console.log('the page2 route executed');
86
+ destroy(scope, nextLocation) {
87
+ scope.replaceChildren();
142
88
  }
143
- };
144
-
89
+ }
145
90
  }
146
91
  };
147
92
  ```
148
93
 
149
- ## Instantiate
94
+ The `secure` function must return exactly `true` to allow navigation. When navigation succeeds, the previous route's `destroy()` runs before the next route's `initialize()`.
150
95
 
151
- ```
152
- const myRouter = new MyRouter();
153
-
154
- myRouter.initialize();
155
- ```
96
+ ### Route matching behavior
156
97
 
157
- ## HTML Linking To Routes
98
+ Routes match complete path boundaries in object insertion order. For example, `/products` matches `/products` and `/products/42`, but it does not match `/products-old`. Put more specific routes before broader ones:
158
99
 
159
- you can make any anchor tigger a specified route by simply setting the href attribute to your route path and adding the attribute 'data-pushstate'.
160
-
161
- ```
162
- <a href="/page2" data-pushstate>take me to page2</a>
100
+ ```js
101
+ this.routes = {
102
+ '/products/sale': saleRoute,
103
+ '/products': productsRoute,
104
+ defaultRoute
105
+ };
163
106
  ```
164
107
 
165
- ## Navigate
166
-
167
- you can also trigger navigation to a route with the navigate method
108
+ If no prefix matches, `defaultRoute` is used when present.
168
109
 
169
- ```
170
- myRouter.navigate('/page2');
171
- ```
110
+ ## Navigate from HTML
172
111
 
173
- ## Mediator
174
-
175
- Optionally you can use a mediator, such as White Label Mediator, with the router. To do so you would like this:
112
+ The router delegates one document-level click listener. Add `data-pushstate` to an anchor to use client-side navigation:
176
113
 
114
+ ```html
115
+ <a href="/products/42" data-pushstate>
116
+ <span>View product 42</span>
117
+ </a>
177
118
  ```
178
- const MyRoute = class extends Router {
179
- constructor() {
180
- super();
181
119
 
182
- // the container element where all views will be placed
183
- this.scope = document.querySelector('body');
120
+ Clicks on nested elements such as the `span` are resolved to the enclosing anchor. Links without `data-pushstate` keep their normal browser behavior.
184
121
 
185
- this.mediator = myMediator;
122
+ Modified clicks, non-left clicks, downloads, links targeting another browsing context, and cross-origin URLs also keep their normal browser behavior.
186
123
 
187
- this.routes = {
188
- defaultRoute: (scope, locationData) => {
189
- //here you would put any view specific logic for the defaultRoute
190
- window.console.log('the defaultRoute executed');
191
- },
192
- '/page2': (scope, locationData) => {
193
- //here you would put any view specific logic for the page2 route
194
- window.console.log('the page2 route executed');
195
- }
196
- };
124
+ ## Navigate from JavaScript
197
125
 
198
- }
199
- };
200
- ```
201
-
202
- Then anywhere else in the application you want to trigger the router to navigate you could do so like this:
203
-
204
- ```
205
- myMediator.emit('router:navigate', {
206
- url: '/authentication',
207
- message: 'Session expired, please login again.'
126
+ ```js
127
+ router.navigate('/products/42', {
128
+ source: 'featured-products'
208
129
  });
209
130
  ```
210
131
 
211
- ## Scope
212
-
213
- Optionally you and provide a DOM element to be the scope of the router. That DOM object will then be passed into all your routes as they are executed. To do so you would like this:
214
-
215
- ```
216
- const MyRoute = class extends Router {
217
- constructor() {
218
- super();
219
-
220
- // the container element where all views will be placed
221
- this.scope = document.querySelector('body');
132
+ The second argument becomes `location.data.mediator`. Unless the call represents a browser `popstate`, successful navigation adds the URL to browser history.
222
133
 
223
- this.routes = {
224
- defaultRoute: (scope, locationData) => {
225
- //here you would put any view specific logic for the defaultRoute
226
- window.console.log('the defaultRoute executed');
227
- },
228
- '/page2': (scope, locationData) => {
229
- //here you would put any view specific logic for the page2 route
230
- window.console.log('the page2 route executed');
231
- }
232
- };
134
+ ## Location data
233
135
 
234
- }
235
- };
236
- ```
136
+ Every route receives the configured `scope` and a location object:
237
137
 
238
- ## Passing Data
239
-
240
- The router passes in a location data object to the route that is being executed. Here is an example object passed:
241
-
242
- ```
138
+ ```js
243
139
  {
244
- url: ['/user'],
140
+ url: '/products/42?color=blue',
245
141
  data: {
246
- url: [],
247
- mediator: {},
248
- query: {}
142
+ url: ['42'],
143
+ mediator: {
144
+ source: 'featured-products'
145
+ },
146
+ query: {
147
+ color: 'blue'
148
+ }
249
149
  }
250
150
  }
251
151
  ```
252
152
 
253
- 'data.url' is an array of values parsed from the url being requested. For Example if the url was '/user/fred' and we defined a route for '/user' then data object passed into the '/user' route would be the following:
153
+ - `url` is the complete route URL.
154
+ - `data.url` contains path segments after the matched route prefix.
155
+ - `data.mediator` contains the object passed to `navigate()` or received from the mediator.
156
+ - `data.query` contains parsed query values.
254
157
 
255
- ```
256
- {
257
- url: ['/user/fred'],
258
- data: {
259
- url: ['fred'],
260
- mediator: {},
261
- query: {}
262
- }
263
- }
264
- ```
158
+ Path segments and query values are URI-decoded with browser-native URL APIs. Application code must still validate values before using them.
265
159
 
266
- 'data.query' is an object of values parsed from the url query string. For Example if the url was '/user?name=fred' and we defined a route for '/user' then data object passed into the '/user' route would be the following:
160
+ ## Version 3 migration note
267
161
 
268
- ```
269
- {
270
- url: ['/user?name=fred'],
271
- data: {
272
- url: [],
273
- mediator: {},
274
- query: {
275
- name: 'fred'
276
- }
277
- }
278
- }
279
- ```
162
+ Version 3 changes route matching from unrestricted string prefixes to path boundaries. A route such as `/page2` no longer matches `/page23`. Define `/page23` explicitly if the application previously depended on that behavior.
280
163
 
281
- 'data.mediator' is an object of values parsed through the optional mediator. For Example if this was emitted through the mediator:
164
+ ## Mediator navigation
282
165
 
283
- ```
284
- myMediator.emit('router:navigate', {
285
- url: '/authentication',
286
- message: 'Session expired, please login again.'
166
+ Assign an EventEmitter-compatible mediator before initialization:
167
+
168
+ ```js
169
+ import Mediator from 'white-label-mediator';
170
+
171
+ const mediator = new Mediator();
172
+ const router = new ApplicationRouter();
173
+ router.mediator = mediator;
174
+ router.initialize();
175
+
176
+ mediator.emit('router:navigate', {
177
+ url: '/account',
178
+ reason: 'Session refreshed'
287
179
  });
288
180
  ```
289
181
 
290
- Then the location data passed in to the '/authentication' route would be the following:
182
+ The complete emitted object is passed to the route as `location.data.mediator`. `destroy()` removes the document, window, and mediator listeners. Repeated listener initialization is idempotent and does not add duplicate listeners.
291
183
 
292
- ```
293
- {
294
- url: ['/authentication'],
295
- data: {
296
- url: [],
297
- mediator: {
298
- url: '/authentication',
299
- message: 'Session expired, please login again.'
300
- }
301
- }
302
- }
303
- ```
184
+ ## Lifecycle
304
185
 
305
- ## Let's look at an example:
186
+ | Method | Behavior |
187
+ | --- | --- |
188
+ | `initialize()` | Reads the browser URL, adds listeners, and runs the initial route. |
189
+ | `navigate(url, data, isPopState)` | Matches and runs a route, updates history, and returns the router; returns `false` when blocked or invalid. |
190
+ | `addListeners()` | Adds browser and optional mediator listeners once. |
191
+ | `removeListeners()` | Removes listeners previously added by this router. |
192
+ | `destroy()` | Calls `removeListeners()` and returns the router. |
306
193
 
307
- import the module
194
+ Call `destroy()` when the router is no longer used:
308
195
 
309
- ```
310
- import Router from 'white-label-router';
196
+ ```js
197
+ router.destroy();
311
198
  ```
312
199
 
313
- extend the router
200
+ ## Development
314
201
 
202
+ ```sh
203
+ npm ci
204
+ npm run build
205
+ npm run typecheck
206
+ npm test
207
+ npm run coverage
208
+ npm run audit
315
209
  ```
316
- const myRouter = class extends Router {
317
- // this is the constructor. This executed whenever the view is instantiated.
318
- constructor() {
319
210
 
320
- super();
211
+ The npm package publishes the compiled `dist` file and this README.
321
212
 
322
- // the container element where all views will be placed
323
- this.scope = document.querySelector('body');
213
+ ## TypeScript development and version 4.0.0 migration
324
214
 
325
- this.routes = {
326
- defaultRoute: (scope, locationData) => {
327
- //here you would put any view specific logic for the defaultRoute
328
- window.console.log('the defaultRoute executed');
329
- },
330
- '/page2': (scope, locationData) => {
331
- //here you would put any view specific logic for the page2 route
332
- window.console.log('the page2 route executed');
333
- }
334
- };
335
-
336
- }
337
- };
215
+ Implementation code now uses strict TypeScript. Builds emit JavaScript, source maps with embedded source, and `.d.ts` declarations into `dist`. JavaScript callers can still use the package without compiling TypeScript themselves. JSDoc comments describe parameters, return values, lifecycle behavior, and validation at the implementation, and are retained in declarations.
338
216
 
339
- ```
340
-
341
- instantiate your router
217
+ ```ts
218
+ import Router from 'white-label-router';
342
219
 
343
- ```
344
- const myRouter = new MyRoute();
220
+ const router = new Router();
221
+ const greeting: Router.Route = (scope, location) => {
222
+ console.log(location.data.query.name); // /hello?name=Ada
223
+ };
224
+ router.routes['/hello'] = greeting;
225
+ router.initialize();
345
226
  ```
346
227
 
347
- initialize your router
228
+ `Router.Route` covers function routes and objects with a guard or view lifecycle. `Router.Location` describes decoded URL data, `Router.Navigation` describes mediator payloads, and `Router.Handler` describes a route callback. Browser DOM types are required when compiling a consuming application.
348
229
 
349
- ```
350
- myRouter.initialize();
351
- ```
230
+ This is a major release because the distribution is now CommonJS emitted by TypeScript, replacing the previous UMD wrapper. CommonJS `require` and the documented ESM imports remain supported. Direct AMD loading or browser script tags that depended on UMD globals must migrate to a browser bundler. Edit `src/*.ts`, then run `npm run build`; do not edit generated `dist` files. The obsolete Babel build dependencies have been removed.
352
231
 
353
- navigate to '/page2'
232
+ ### Verification and coverage
354
233
 
234
+ ```sh
235
+ npm ci --ignore-scripts
236
+ npm run typecheck
237
+ npm test
238
+ npm run coverage
239
+ npm pack --dry-run
355
240
  ```
356
- myRouter.navigate('/page2');
357
- ```
358
241
 
359
- In the example above we have set up two routes. The first route 'defaultRoute' is a catch all route. If no other routes match the specified url path this is the route that will be executed. In this example the defaultRoute would be executed for 'http://example.com' or 'http://example.com/home', but not 'http://example.com/page2'.
242
+ `npm test` builds the code, checks TypeScript consumer examples against the emitted declarations, and runs the tests. `npm run coverage` additionally enforces **100% statements, branches, functions, and lines for each implementation file**. Unexecuted implementation files count toward the result; declaration-only files contain no executable code and are excluded. Reports are written to `coverage`, including `lcov.info` for coverage viewers. CI runs the same gate and checks committed build output for drift.
243
+
244
+ Tests exercise the compiled JavaScript interface used by downstream callers. Coverage is an execution metric, not proof that all possible inputs or external integrations are correct.
360
245
 
361
- The second defined route 'page2' will only be executed when the specified url path starts with '/page2', or for example 'http://example.com/page2'.
246
+ To undo this migration, revert its commit and run `npm ci` from the restored lockfile. No npm release, database migration, or production deployment is performed by these development changes.
@@ -0,0 +1,111 @@
1
+ /** Navigation payload, including caller-defined metadata. */
2
+ interface NavigationData {
3
+ url?: string;
4
+ [key: string]: unknown;
5
+ }
6
+ /** URL-derived values supplied to route handlers. */
7
+ interface LocationData {
8
+ url: string;
9
+ data: {
10
+ url: string[];
11
+ mediator: NavigationData | undefined;
12
+ query: Record<string, string>;
13
+ };
14
+ }
15
+ type RouteHandler = (scope: Element | null, location: LocationData) => unknown;
16
+ /** An object route can own a function or a view lifecycle. */
17
+ interface RouteObject {
18
+ title?: string;
19
+ secure?: RouteHandler;
20
+ view?: RouteHandler | {
21
+ initialize?: RouteHandler;
22
+ destroy?: RouteHandler;
23
+ };
24
+ }
25
+ type Route = RouteHandler | RouteObject;
26
+ /** Minimal event-bus contract required by the router. */
27
+ interface NavigationMediator {
28
+ on(event: string, callback: (data?: NavigationData) => unknown): unknown;
29
+ removeListener?(event: string, callback: (data?: NavigationData) => unknown): unknown;
30
+ }
31
+ /** History API router with ordered path-boundary matching. */
32
+ declare class Router {
33
+ routes: Record<string, Route>;
34
+ url: string;
35
+ route: string | null;
36
+ scope: Element | null;
37
+ previousRoute: string | null;
38
+ pageTitle: string | null;
39
+ mediator: NavigationMediator | false;
40
+ boundPushStateClick: (event: MouseEvent) => unknown;
41
+ boundPopState: (event: PopStateEvent) => unknown;
42
+ boundMediatorNavigate: (data?: NavigationData) => unknown;
43
+ listenersInitialized: boolean;
44
+ locationData: LocationData;
45
+ /**
46
+ * Create an instance with its own state and listener references.
47
+ */
48
+ constructor();
49
+ /**
50
+ * Start this instance and return it for lifecycle chaining.
51
+ * @returns This instance for chaining.
52
+ */
53
+ initialize(): this;
54
+ /**
55
+ * Register stable document, history, and optional mediator handlers once.
56
+ * @returns This instance for chaining.
57
+ */
58
+ addListeners(): this;
59
+ /**
60
+ * Release owned state and listeners so the instance can leave the application lifecycle.
61
+ * @returns This instance after cleanup.
62
+ */
63
+ destroy(): this;
64
+ /**
65
+ * Release listeners owned by this instance; subclasses may extend the lifecycle hook.
66
+ * @returns This instance for chaining.
67
+ */
68
+ removeListeners(): this;
69
+ /**
70
+ * Intercept eligible same-origin push-state links while preserving normal browser actions.
71
+ * @param e - Browser event being handled.
72
+ * @returns True when the browser should handle the event, or this router after interception.
73
+ */
74
+ eventPushStateClick(e: MouseEvent): true | this;
75
+ /**
76
+ * Restore browser history state without adding a new history entry.
77
+ * @param e - Browser event being handled.
78
+ * @returns This router after restoring the history state.
79
+ */
80
+ eventPopState(e: PopStateEvent): this;
81
+ /**
82
+ * Decode URL query parameters, keeping the last value for duplicate keys.
83
+ * @param queryString - Encoded URL query portion.
84
+ * @returns A record of decoded query names and their final values.
85
+ */
86
+ parseQueryString(queryString: string): {
87
+ [k: string]: string;
88
+ };
89
+ /**
90
+ * Build decoded path, query, and mediator data for the selected route.
91
+ * @param mediatorData - Optional caller metadata passed to the route.
92
+ * @returns No value; locationData is updated in place.
93
+ */
94
+ setLocationData(mediatorData?: NavigationData): void;
95
+ /**
96
+ * Select a route, enforce its guard, transition view lifecycles, and update history and title.
97
+ * @param url - URL or optional adapter argument.
98
+ * @param mediatorData - Optional caller metadata passed to the route.
99
+ * @param isPopState - Whether navigation came from browser history.
100
+ * @returns False when a guard or route rejects navigation; otherwise this router.
101
+ */
102
+ navigate(url?: string | false, mediatorData?: NavigationData, isPopState?: boolean): false | this;
103
+ }
104
+ /** Public route configuration and navigation payload types. */
105
+ declare namespace Router {
106
+ type Handler = RouteHandler;
107
+ type Route = RouteHandler | RouteObject;
108
+ type Location = LocationData;
109
+ type Navigation = NavigationData;
110
+ }
111
+ export = Router;