@dharmax/state-router 1.2.1 → 2.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
@@ -6,48 +6,43 @@ What is a functional router? it's a router that captures a url changes and
6
6
  per specific pattern of url, it simply triggers a handler. It supports hash notation
7
7
  as well as normal url patterns. It also supports contexts (url parameters).
8
8
 
9
- Together with the **state manager** included here - which will be the main object you'd need
10
- to work with, you get a very friendly and strong semantic application state management:
9
+ Together with the state manager - which will be the main object you'd need
10
+ to work with, you get a very simple semantic application state management:
11
11
  each state can be given a logical name, route and an associated page/component.
12
12
 
13
- # Features
14
- * Very easy to use
15
- * Purely Functional - no need for anything by simple JS
16
- * Only one tiny dependency
17
- * Easily allow any authorization logic to be used
18
- * Tiny footprint
19
- * Doesn't monopolise the URL
20
- * Supports state contexts
13
+ The router by default ignores file extensions '.json', '.css', '.js', '.png', '.jpg', '.svg', '.webp','md'
14
+ and you can access the router's staticFilters member to replace or add other rules
15
+ for static serving support.
16
+
17
+ you can use the hash notation or not (set the router's mode to 'history' or 'hash')
18
+
21
19
 
22
20
  # Example
23
21
 
24
22
  ## state definitions
25
23
 
26
24
  ```javascript
27
-
28
- // this is how you define the states: logical name, component name, route (as string or regex)
29
25
  stateManager.addState('main', 'main-page', /main$/);
30
26
  stateManager.addState('login', 'login-box', 'login')
31
27
  stateManager.addState('signup', 'signup-box', /signup/)
32
28
  stateManager.addState('my-profile') // will assume the page name and the route are the same...
33
- stateManager.addState('inbox') // automatically assume the logical name, component and route are the same
29
+ stateManager.addState('inbox')
34
30
  stateManager.addState('about')
35
- stateManager.addState('discussion', 'discussion-page', 'discussion/%') // route with parameter (the % is added to the state's context )
36
-
37
- // this is an example of how you handle the state change (navigation events)
38
- stateManager.onChange( event => {
39
- // this specific example works with RiotJs, but you get the drift
40
- this.update({
41
- currentPage: event.data.pageName
42
- })
43
- })
31
+ stateManager.addState('discussion', 'discussion-page', 'discussion/%')
32
+ ```
33
+
34
+ ## usage in the gui
44
35
 
45
- // this is how you can add hooks by which you can block navigation based
46
- // on any logic you want.
47
- stateManager.registerChangeAuthority( async targetState => {
48
- if ( ! await PermissionManager.isUserAllowedToSeePage(targetState.pageName))
49
- return false
50
- return openConfirmDialog('Confirm leaving this page')
36
+ In your main component, you write something like that:
37
+
38
+ ```javascript
39
+
40
+ stateManager.onChange( event => {
41
+ // this specific example works with RiotJs, but you get the drift
42
+ this.update({
43
+ currentPage: event.data.pageName
51
44
  })
52
-
45
+ })
46
+
47
+
53
48
  ```
package/dist/router.d.ts CHANGED
@@ -1,21 +1,21 @@
1
- declare class Route {
2
- constructor();
3
- re: RegExp;
4
- handler: Function;
5
- }
1
+ type RouteHandler = (...args: string[]) => void;
6
2
  export type RoutingMode = 'history' | 'hash';
7
- export declare const router: {
3
+ declare class Router {
8
4
  mode: RoutingMode;
9
- routes: Route[];
10
- root: string;
11
- baseLocation: string | null;
5
+ private routes;
6
+ private root;
7
+ private baseLocation;
8
+ staticFilters: ((url: string) => boolean)[];
9
+ constructor();
10
+ private clearSlashes;
11
+ private clearQuery;
12
+ private isStaticFile;
12
13
  resetRoot(root: string): void;
13
14
  getLocation(): string;
14
- clearSlashes(path: string): string;
15
- clearQuery(url: string): string;
16
- add(re: Function | RegExp, handler: Function): any;
17
- process(location?: string): any;
18
- listen(): any;
19
- navigate(path?: string): any;
20
- };
15
+ add(pattern: RegExp | RouteHandler, handler?: RouteHandler): Router;
16
+ process(location?: string): Router;
17
+ listen(): void;
18
+ navigate(path?: string): Router;
19
+ }
20
+ export declare const router: Router;
21
21
  export {};
package/dist/router.js CHANGED
@@ -1,94 +1,81 @@
1
- class Route {
1
+ class Router {
2
+ mode = 'hash';
3
+ routes = [];
4
+ root = '/';
5
+ baseLocation = null;
6
+ staticFilters = [];
2
7
  constructor() {
3
- this.re = null;
4
- this.handler = null;
8
+ this.staticFilters.push(url => {
9
+ const staticFileExtensions = ['.json', '.css', '.js', '.png', '.jpg', '.svg', '.webp', 'md'];
10
+ return staticFileExtensions.some(ext => url.endsWith(ext));
11
+ });
12
+ window.addEventListener(this.mode === 'hash' ? 'hashchange' : 'popstate', this.listen.bind(this));
5
13
  }
6
- }
7
- export const router = new class {
8
- constructor() {
9
- this.mode = 'hash';
10
- this.routes = [];
11
- this.root = '/';
12
- this.baseLocation = null;
14
+ clearSlashes(path) {
15
+ return path.replace(/\/$/, '').replace(/^\//, '');
16
+ }
17
+ clearQuery(url) {
18
+ const [path, query] = url.split('?');
19
+ if (!query)
20
+ return path;
21
+ const [_, hash] = query.split('#');
22
+ return hash ? `${path}#${hash}` : path;
23
+ }
24
+ isStaticFile(url) {
25
+ return (this.staticFilters || []).some(filter => filter(url));
13
26
  }
14
27
  resetRoot(root) {
15
28
  this.root = '/' + this.clearSlashes(root) + '/';
16
29
  }
17
30
  getLocation() {
18
- let fragment = '';
19
31
  if (this.mode === 'history') {
20
- fragment = this.clearSlashes(decodeURI(location.pathname + location.search));
32
+ let fragment = this.clearSlashes(decodeURI(window.location.pathname + window.location.search));
21
33
  fragment = this.clearQuery(fragment);
22
- fragment = fragment.replace(/\?(.*)$/, '');
23
- fragment = this.root != '/' ? fragment.replace(this.root, '') : fragment;
34
+ return this.root !== '/' ? fragment.replace(this.root, '') : fragment;
24
35
  }
25
36
  else {
26
37
  const match = window.location.href.match(/#(.*)$/);
27
- fragment = this.clearQuery(fragment);
28
- fragment = match ? match[1] : '';
38
+ return match ? this.clearQuery(match[1]) : '';
29
39
  }
30
- return this.clearSlashes(fragment);
31
- }
32
- clearSlashes(path) {
33
- return path.toString().replace(/\/$/, '').replace(/^\//, '');
34
- }
35
- clearQuery(url) {
36
- if (url.indexOf('?') === -1)
37
- return url;
38
- const a = url.split('?');
39
- const afterHash = a[1].split('#');
40
- if (!afterHash)
41
- return a[0];
42
- return `${a[0]}#${afterHash}`;
43
40
  }
44
- add(re, handler) {
45
- if (typeof re == 'function') {
46
- handler = re;
47
- re = null;
41
+ add(pattern, handler) {
42
+ if (typeof pattern === 'function') {
43
+ handler = pattern;
44
+ pattern = /^.*$/; // Match any path
48
45
  }
49
- this.routes.push({ re: re, handler });
46
+ this.routes.push({ pattern, handler: handler });
50
47
  return this;
51
48
  }
52
49
  process(location) {
53
- const fragment = location || this.getLocation();
54
- const matches = this.routes.filter((r) => fragment.match(r.re))
55
- .sort((r1, r2) => {
56
- const [n1, n2] = [r1, r2].map(r => r.re.source.split('/'));
57
- return n2.length - n1.length;
58
- })
59
- .map(r => {
60
- return { r, match: fragment.match(r.re) };
61
- });
62
- if (!matches.length) {
63
- console.warn(`No routing found for ${fragment}`);
64
- return;
50
+ const path = location || this.getLocation();
51
+ if (this.isStaticFile(path))
52
+ return this; // Bypass routing for static files
53
+ for (const route of this.routes) {
54
+ const match = path.match(route.pattern);
55
+ if (match) {
56
+ match.shift(); // Remove the full match element
57
+ route.handler.apply({}, match);
58
+ return this;
59
+ }
65
60
  }
66
- const longestMatch = matches[0];
67
- longestMatch.match.shift();
68
- longestMatch.r.handler.apply({}, longestMatch.match);
61
+ console.warn(`No routing found for ${path}`);
69
62
  return this;
70
63
  }
71
64
  listen() {
72
- this.baseLocation = this.getLocation();
73
- window[this.mode === 'hash' ? 'onhashchange' : 'onpopstate'] = () => {
74
- const place = this.getLocation();
75
- if (this.baseLocation !== place) {
76
- this.baseLocation = place;
77
- this.process(place);
78
- }
79
- };
80
- return this;
65
+ const currentLocation = this.getLocation();
66
+ if (this.baseLocation !== currentLocation) {
67
+ this.baseLocation = currentLocation;
68
+ this.process(currentLocation);
69
+ }
81
70
  }
82
- navigate(path) {
83
- path = path ? path : '';
71
+ navigate(path = '') {
84
72
  if (this.mode === 'history') {
85
73
  history.pushState(null, null, this.root + this.clearSlashes(path));
86
74
  }
87
75
  else {
88
- path = path.startsWith('#') ? path : '#' + path;
89
- window.location.href = window.location.href.replace(/#(.*)$/, '') + path;
90
- this.process();
76
+ window.location.hash = '#' + this.clearSlashes(path);
91
77
  }
92
78
  return this;
93
79
  }
94
- };
80
+ }
81
+ export const router = new Router();
@@ -7,17 +7,14 @@ export type ApplicationState = {
7
7
  route: RegExp;
8
8
  mode?: string | string[];
9
9
  };
10
- export type ChangeAuthority = (state: ApplicationState) => Promise<boolean>;
11
10
  export declare class StateManager {
12
11
  private allStates;
13
12
  private appState;
14
13
  private previousState;
15
14
  private stateContext;
16
- static dispatcher: import("@dharmax/pubsub").PubSub;
17
- private changeAuthorities;
15
+ static dispatcher: import("@dharmax/pubsub").Pubsub;
18
16
  constructor(mode?: RoutingMode);
19
17
  onChange(handler: (event: PubSubEvent, data: any) => void): IPubSubHandle;
20
- registerChangeAuthority(authorityCallback: (state: ApplicationState) => Promise<boolean>): void;
21
18
  getState(): ApplicationState;
22
19
  get previous(): ApplicationState;
23
20
  get context(): ApplicationState;
@@ -33,7 +30,7 @@ export declare class StateManager {
33
30
  * @param stateName state
34
31
  * @param context extra context (e.g. sub-state)
35
32
  */
36
- setState(stateName: ApplicationStateName, context?: any): Promise<boolean>;
33
+ setState(stateName: ApplicationStateName, context?: any): boolean;
37
34
  /**
38
35
  * Define an application state
39
36
  * @param name
@@ -1,23 +1,18 @@
1
1
  import { router } from "./router";
2
2
  import dispatcher from "@dharmax/pubsub";
3
3
  export class StateManager {
4
+ allStates = {};
5
+ appState;
6
+ previousState;
7
+ stateContext;
8
+ static dispatcher = dispatcher;
4
9
  constructor(mode = 'hash') {
5
- this.allStates = {};
6
- this.changeAuthorities = [];
7
10
  router.mode = mode;
8
11
  router.listen();
9
12
  }
10
13
  onChange(handler) {
11
14
  return StateManager.dispatcher.on('state:changed', handler);
12
15
  }
13
- /*
14
- Add a hook which enable conditional approval of state change. It can be more than one; when a state
15
- change is requested, all the registered authorities must return true (asynchronously) otherwise the change
16
- requested doesn't happen.
17
- **/
18
- registerChangeAuthority(authorityCallback) {
19
- this.changeAuthorities.push(authorityCallback);
20
- }
21
16
  getState() {
22
17
  return this.appState || {};
23
18
  }
@@ -40,7 +35,7 @@ export class StateManager {
40
35
  /** attempts to restore state from current url */
41
36
  restoreState(defaultState) {
42
37
  let dest = window.location.hash;
43
- if (dest === '#login' || dest === '')
38
+ if (dest == '#login' || dest == '')
44
39
  dest = '#' + defaultState;
45
40
  router.navigate(dest);
46
41
  }
@@ -49,17 +44,12 @@ export class StateManager {
49
44
  * @param stateName state
50
45
  * @param context extra context (e.g. sub-state)
51
46
  */
52
- async setState(stateName, context) {
47
+ setState(stateName, context) {
53
48
  const newState = this.allStates[stateName];
54
49
  if (!newState) {
55
50
  alert(`Undefined app state ${stateName}`);
56
51
  return false;
57
52
  }
58
- // check if the state change was declined by any change authority and if so - don't do it and return false
59
- const changeConfirmations = await Promise.all(this.changeAuthorities.map(a => a(newState)));
60
- if (changeConfirmations.includes(false))
61
- return false;
62
- // perform the change
63
53
  this.previousState = this.appState;
64
54
  this.stateContext = context;
65
55
  this.appState = newState;
@@ -89,8 +79,8 @@ export class StateManager {
89
79
  }
90
80
  registerStateByState(state) {
91
81
  this.allStates[state.name] = state;
92
- router.add(state.route, async (context) => {
93
- if (await this.setState(state.name, context)) {
82
+ router.add(state.route, (context) => {
83
+ if (this.setState(state.name, context)) {
94
84
  // @ts-ignore
95
85
  if (window.ga) {
96
86
  // @ts-ignore
@@ -100,5 +90,4 @@ export class StateManager {
100
90
  });
101
91
  }
102
92
  }
103
- StateManager.dispatcher = dispatcher;
104
93
  export const stateManager = new StateManager();
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@dharmax/state-router",
3
- "version": "1.2.1",
3
+ "version": "2.0.0",
4
4
  "description": "A cute and tight router and application state controller",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
5
+ "main": "dist/router.js",
6
+ "types": "dist/router.d.ts",
7
7
  "scripts": {
8
8
  "test": "echo \"Error: no test specified\" && exit 1",
9
9
  "build": "npx tsc",
@@ -15,7 +15,6 @@
15
15
  },
16
16
  "keywords": [
17
17
  "router",
18
- "navigation",
19
18
  "state"
20
19
  ],
21
20
  "author": "Avi Tshuva, dharmax",
@@ -25,9 +24,9 @@
25
24
  },
26
25
  "homepage": "https://github.com/dharmax/state-routerr",
27
26
  "devDependencies": {
28
- "typescript": "^4.9.3"
27
+ "typescript": "^4.9.5"
29
28
  },
30
29
  "dependencies": {
31
- "@dharmax/pubsub": "^1.1.0"
30
+ "@dharmax/pubsub": "^1.0.1"
32
31
  }
33
32
  }
package/src/router.ts CHANGED
@@ -1,108 +1,102 @@
1
- class Route {
2
- constructor() {
3
- }
1
+ type RouteHandler = (...args: string[]) => void;
4
2
 
5
- re: RegExp = null
6
- handler: Function = null
3
+ interface Route {
4
+ pattern: RegExp | null;
5
+ handler: RouteHandler;
7
6
  }
8
7
 
9
- export type RoutingMode = 'history' | 'hash'
10
- export const router = new class {
8
+ export type RoutingMode = 'history' | 'hash';
11
9
 
12
- mode: RoutingMode = 'hash'
13
- routes: Route[] = []
14
- root = '/'
10
+ class Router {
11
+ mode: RoutingMode = 'hash';
12
+ private routes: Route[] = [];
13
+ private root: string = '/';
14
+ private baseLocation: string | null = null;
15
+ public staticFilters:((url:string) => boolean)[] = []
15
16
 
16
- baseLocation: string | null = null;
17
+ constructor() {
18
+ this.staticFilters.push( url => {
19
+ const staticFileExtensions = ['.json', '.css', '.js', '.png', '.jpg', '.svg', '.webp','md'];
20
+ return staticFileExtensions.some(ext => url.endsWith(ext));
17
21
 
18
- resetRoot(root: string) {
19
- this.root = '/' + this.clearSlashes(root) + '/';
22
+ })
23
+ window.addEventListener(this.mode === 'hash' ? 'hashchange' : 'popstate', this.listen.bind(this));
20
24
  }
21
25
 
26
+ private clearSlashes(path: string): string {
27
+ return path.replace(/\/$/, '').replace(/^\//, '');
28
+ }
22
29
 
23
- getLocation() {
24
- let fragment = '';
25
- if (this.mode === 'history') {
26
- fragment = this.clearSlashes(decodeURI(location.pathname + location.search));
27
- fragment = this.clearQuery(fragment)
28
- fragment = fragment.replace(/\?(.*)$/, '');
29
- fragment = this.root != '/' ? fragment.replace(this.root, '') : fragment;
30
- } else {
31
- const match = window.location.href.match(/#(.*)$/);
32
- fragment = this.clearQuery(fragment)
33
- fragment = match ? match[1] : '';
34
- }
35
- return this.clearSlashes(fragment);
30
+ private clearQuery(url: string): string {
31
+ const [path, query] = url.split('?');
32
+ if (!query) return path;
33
+ const [_, hash] = query.split('#');
34
+ return hash ? `${path}#${hash}` : path;
36
35
  }
37
36
 
38
- clearSlashes(path: string): string {
39
- return path.toString().replace(/\/$/, '').replace(/^\//, '');
37
+ private isStaticFile(url: string): boolean {
38
+ return (this.staticFilters || []).some( filter => filter(url))
40
39
  }
41
40
 
42
- clearQuery(url: string): string {
43
- if (url.indexOf('?') === -1)
44
- return url
45
- const a = url.split('?')
46
- const afterHash = a[1].split('#')
47
- if (!afterHash)
48
- return a[0]
49
- return `${a[0]}#${afterHash}`
41
+ public resetRoot(root: string): void {
42
+ this.root = '/' + this.clearSlashes(root) + '/';
50
43
  }
51
44
 
52
- add(re: Function | RegExp, handler: Function) {
53
- if (typeof re == 'function') {
54
- handler = re;
55
- re = null;
45
+ public getLocation(): string {
46
+ if (this.mode === 'history') {
47
+ let fragment = this.clearSlashes(decodeURI(window.location.pathname + window.location.search));
48
+ fragment = this.clearQuery(fragment);
49
+ return this.root !== '/' ? fragment.replace(this.root, '') : fragment;
50
+ } else {
51
+ const match = window.location.href.match(/#(.*)$/);
52
+ return match ? this.clearQuery(match[1]) : '';
56
53
  }
57
- this.routes.push({re: re as RegExp, handler});
58
- return this;
59
54
  }
60
55
 
61
- process(location?: string) {
62
- const fragment = location || this.getLocation();
63
- const matches = this.routes.filter((r: any) => fragment.match(r.re))
64
-
65
- .sort((r1, r2) => {
66
- const [n1, n2] = [r1, r2].map(r => r.re.source.split('/'))
67
- return n2.length - n1.length
68
- }
69
- )
70
- .map(r => {
71
- return {r, match: fragment.match(r.re)}
72
- });
73
- if (!matches.length) {
74
- console.warn(`No routing found for ${fragment}`)
75
- return
56
+ public add(pattern: RegExp | RouteHandler, handler?: RouteHandler): Router {
57
+ if (typeof pattern === 'function') {
58
+ handler = pattern;
59
+ pattern = /^.*$/; // Match any path
76
60
  }
77
-
78
- const longestMatch = matches[0]
79
- longestMatch.match.shift()
80
- longestMatch.r.handler.apply({}, longestMatch.match)
61
+ this.routes.push({ pattern, handler: handler as RouteHandler });
81
62
  return this;
82
63
  }
83
64
 
84
- listen() {
85
- this.baseLocation = this.getLocation();
86
- window[this.mode === 'hash' ? 'onhashchange' : 'onpopstate'] = () => {
87
- const place = this.getLocation();
88
- if (this.baseLocation !== place) {
89
- this.baseLocation = place;
90
- this.process(place);
65
+ public process(location?: string): Router {
66
+ const path = location || this.getLocation();
67
+ if (this.isStaticFile(path))
68
+ return this; // Bypass routing for static files
69
+
70
+
71
+ for (const route of this.routes) {
72
+ const match = path.match(route.pattern);
73
+ if (match) {
74
+ match.shift(); // Remove the full match element
75
+ route.handler.apply({}, match);
76
+ return this;
91
77
  }
92
78
  }
79
+
80
+ console.warn(`No routing found for ${path}`);
93
81
  return this;
94
82
  }
95
83
 
96
- navigate(path?: string) {
97
- path = path ? path : '';
84
+ listen(): void {
85
+ const currentLocation = this.getLocation();
86
+ if (this.baseLocation !== currentLocation) {
87
+ this.baseLocation = currentLocation;
88
+ this.process(currentLocation);
89
+ }
90
+ }
91
+
92
+ public navigate(path: string = ''): Router {
98
93
  if (this.mode === 'history') {
99
94
  history.pushState(null, null, this.root + this.clearSlashes(path));
100
95
  } else {
101
- path = path.startsWith('#') ? path : '#' + path
102
- window.location.href = window.location.href.replace(/#(.*)$/, '') + path;
103
- this.process()
96
+ window.location.hash = '#' + this.clearSlashes(path);
104
97
  }
105
98
  return this;
106
99
  }
107
100
  }
108
101
 
102
+ export const router = new Router();
@@ -88,8 +88,8 @@ export class StateManager {
88
88
  }
89
89
 
90
90
  // check if the state change was declined by any change authority and if so - don't do it and return false
91
- const changeConfirmations = await Promise.all(this.changeAuthorities.map( authority => authority(newState) ))
92
- if (changeConfirmations.find( c => c === false))
91
+ const changeConfirmations = await Promise.all(this.changeAuthorities.map(authority => authority(newState)))
92
+ if (changeConfirmations.includes(false))
93
93
  return false
94
94
 
95
95
  // perform the change
package/tsconfig.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "compilerOptions": {
3
- "target": "ES2020",
3
+ "target": "ES2022",
4
4
  "lib": [
5
5
  "DOM",
6
- "ES2020"
6
+ "ES2022"
7
7
  ],
8
8
  "module": "ES6",
9
9
  "moduleResolution": "Node",
@@ -16,6 +16,7 @@
16
16
  "src"
17
17
  ],
18
18
  "exclude": [
19
+ "dist/",
19
20
  "node_modules",
20
21
  "**/__tests__/*"
21
22
  ]