adhara
Version:
foundation for any kind of website: microframework
935 lines (852 loc) • 32.1 kB
JavaScript
/**
* @namespace
* @description
* Client routing logic.
* All the client URL pattern's should be registered with the router.
*
* On visiting a URL, if called {@link AdharaRouter.route}, it calls the registered view function,
* which takes care of rendering the page.
*
* Any HTML element can be used as a router. For this purpose,
*
*
* > Add this attribute `href="/url/to/be/navigated/to"` and this property `route` to any html element to make behave as a router.
*
* > By default routing happening this way will execute the view function even if the current URL is same as provided href.
*
* > In order to disable this behaviour, provide the attribute `data-force="false"`
*
* > In order to use {@link AdharaRouter.goBack} functionality provide `data-force="true"`. See example below.
*
* @example
* //Registering
* AdharaRouter.register("^/adhara/{{instance_id}}([0-9]+)/{{tab}}(details|tokens|history)$", function(instance_id, tab){
* console.log(instance_id, tab);
* AdharaRouter.getPathParam("instance_id"); //Returns same as instance_id
* AdharaRouter.getPathParam("tab"); //Returns same as tab
* });
*
* //Navigating
* AdharaRouter.navigateTo("/adhara/123412341234/details");
*
* //Routing - In case if URL is already set in address bar and not via Router, call this function to execute the registered view funciton.
* this.route();
*
* //HTML Example
*
* <a href="/adhara/123412341234/tokens" />
*
* <button href="/adhara/123412341234/tokens" data-force="false">tokens</button>
*
* <div href="/adhara/123412341234/details" data-back="true"></div>
*
* */
let AdharaRouter = null;
(() => {
"use strict";
/**
* @private
* @member {Object<String, Object>}
* @description
* Stores all the registered URL's along with other view parameters.
* */
let registeredUrlPatterns = {};
/**
* @private
* @member {String}
* @description
* Stores base URI for regex matches
* */
let baseURI = "";
let defaultTitle = document.title;
/**
* @private
* @member {String}
* @description
* App Name to be used as first half of document title
* */
let appName = "";
/**
* @private
* @member {Object<String, String>}
* @description
* Stores the current URL's search query parameters.
* */
let queryParams = {};
/**
* @private
* @member {Object<String, String>}
* @description
* Stores the current URL's path variables.
* */
let pathParams = {};
/**
* @private
* @member {Array<String>}
* @description
* Stores list of visited URL's
* */
let historyStack = [];
/**
* @private
* @member {String|undefined}
* @description
* Stores current page URL.
* */
let currentUrl = undefined;
/**
* @private
* @member {RouterURLConf}
* @description
* Stores route which matches with the current URL against registered URL patterns.
* */
let currentRoute = undefined;
/**
* @private
* @member {Object<String, Function|undefined>}
* @description
* Stores listeners that will be called on routing.
* */
let listeners = {};
/**
* @typedef {Function} AdharaRouterMiddleware
* @param {Object} params - url parameters
* @param {String } params.view_name - name of the page that is being routed to
* @param {String} params.path - path that is being routed to
* @param {Object} params.query_params - url query parameters
* @param {Object} params.path_params - url path parameters
* @param {Function} route - Proceed with routing after making necessary checks in middleware
* */
/**
* @private
* @member {Array<AdharaRouterMiddleware>}
* @description
* Stores middlewares that will be called on routing.
* */
let middlewares = [];
/**
* @function
* @private
* @returns {String} Current window URL. IE compatibility provided by Hostory.js (protocol://domain/path?search_query).
* */
function getFullUrl(){
return window.location.href;
// return History.getState().cleanUrl;
}
/**
* @function
* @private
* @returns {String} Full URL without search query (protocol://domain/path).
* */
function getBaseUrl(){
return getFullUrl().split('?')[0];
}
/**
* @function
* @private
* @returns {String} URL Path (path).
* */
function getPathName(){
return AdharaRouter.transformURL(window.location.pathname);
}
/**
* @function
* @private
* @returns {String} URL Path with search query (/path?search_query).
* */
function getFullPath(){
return getPathName()+window.location.search+window.location.hash;
// return "/"+getFullUrl().split('://')[1].substring(window.location.host.length+1);
}
/**
* @function
* @private
* @returns {String} Search query (search_query).
* */
function getSearchString(){
return window.location.search.substring(1);
}
/**
* @function
* @private
* @returns {String} Hash (text after `#` from the url).
* */
function getHash(){
return window.location.hash.substring(1);
}
/**
* @function
* @private
* @param {String} new_path - URL path.
* @returns {Boolean} Whether new_path matches with current URL path.
* */
function isCurrentPath(new_path){
function stripSlash(path){
if(path.indexOf('/') === 0){
return path.substring(1);
}
return path;
}
return stripSlash(getFullPath()) === stripSlash(new_path);
}
function callMiddlewares(params, proceed){
let i=0;
(function _proceed(){
let middleware_fn = middlewares[i++];
if(middleware_fn){
call_fn(middleware_fn, params, _proceed);
}else{
proceed();
}
})();
}
/**
* @function
* @private
* @description matches the current URL and returns the configuration, path and params
* @returns Object
* */
function matchUrl(){
let path = getPathName();
let matchFound = false;
for(let regex in registeredUrlPatterns){
if(registeredUrlPatterns.hasOwnProperty(regex) && !matchFound) {
let formed_regex = new RegExp(regex);
if (formed_regex.test(path)) {
matchFound = true;
let opts = registeredUrlPatterns[regex];
let params = formed_regex.exec(path);
return {opts, path, params, meta: opts.meta};
}
}
}
}
/**
* @function
* @private
* @description
* Routes to the current URL.
* Looks up in the registered URL patterns for a match to current URL.
* If match found, view function will be called with regex path matches in the matched order and query param's as the last argument.
* Current view name will be set to the view name configured against view URL.
* @returns {Boolean} Whether any view function is found or not.
* */
function matchAndCall(){
let matchOptions = matchUrl();
if(matchOptions){
let {opts, path, params, meta} = matchOptions;
params.splice(0,1);
if(opts && opts.fn){
let _pathParams = {};
for(let [index, param] of params.entries()){
_pathParams[opts.path_param_keys[index]] = param;
}
currentRoute = opts;
//Setting current routing params...
pathParams = _pathParams;
currentUrl = getFullUrl();
fetchQueryParams();
callMiddlewares({
view_name: opts.view_name,
path: path,
query_params: getQueryParams(),
path_params: _pathParams
}, () => {
params.push(queryParams);
document.title = [(appName || defaultTitle), meta.title].filter(_=>_).join(" | ");
if(opts.fn.constructor instanceof AdharaView.constructor){
Adhara.onRoute(opts.fn, params);
}else{
opts.fn.apply(this, params);
}
});
}
}
return !!matchOptions;
}
let curr_path;
function updateHistoryStack(){
if(curr_path){
historyStack.push(curr_path);
}
curr_path = getFullPath();
}
/**
* @private
* @member {Boolean}
* @description
* This flag will be set to true when set to true, {@link Router.route} will not call the view function.
* */
let settingURL = false;
/**
* @function
* @private
* @description
* Updates the {AdharaRouter~queryParams} with the current URL parameters
* */
function updateParams(){
if(getFullUrl() !== currentUrl){
currentUrl = getFullUrl();
fetchQueryParams();
}
}
/**
* @function
* @private
* @description
* Fetches the search query from current URL and transforms it to query param's object.
* @returns {Object<String, String>} The search query will be decoded and returned as an object.
* */
function getQueryParams(){
let qp = {};
if(getSearchString()){
loop(getSearchString().split('&'), function(i, paramPair){
paramPair = paramPair.split('=');
qp[decodeURIComponent(paramPair[0])] = decodeURIComponent(paramPair[1]);
});
}else{
qp = {};
}
return qp;
}
/**
* @function
* @private
* @description
* Fetches the search query from current URL and transforms it to query param's.
* The search query will be decoded and stored.
* */
function fetchQueryParams(){
queryParams = getQueryParams();
}
/**
* @function
* @private
* @returns {String} URL constructed by current base URL and current Query Parameters.
* */
function generateCurrentUrl(){
let url = getPathName();
let params = [];
loop(queryParams, function(key, value){
params.push(encodeURIComponent(key)+"="+encodeURIComponent(value));
});
if(params.length){
url+="?"+params.join("&");
}
return url;
}
/**
* @function
* @private
* @param {RouteType} [route_type=this.routeTypes.NAVIGATE] - How to route.
* @param {Object} route_options - Route options.
* @param {boolean} [route_options.force=false] - Whether to force navigate URL or not. Will call view function even if current URL is same. Applies for {@link RouteType.NAVIGATE}.
* @see {@link RouteType}
* */
function _route(route_type, route_options){
if(!route_type || route_type===AdharaRouter.RouteTypes.SET){
AdharaRouter.setURL(generateCurrentUrl());
}else if(route_type===AdharaRouter.RouteTypes.NAVIGATE){
AdharaRouter.navigateTo(generateCurrentUrl(), (route_options && route_options.force)||false);
}else if(route_type===AdharaRouter.RouteTypes.OVERRIDE){
AdharaRouter.overrideURL(generateCurrentUrl());
}else if(route_type===AdharaRouter.RouteTypes.UPDATE){
AdharaRouter.updateURL(generateCurrentUrl());
}
}
const STATE_KEY = "__adhara_router__";
class Router{
/**
* @function
* @private
* @param {String} url - path
* @description
* modified the URL and returns the new value. Default transformer just returns the passed url/path.
* */
static transformURL(url){
if(url.startsWith(baseURI)){
return url;
}
return baseURI+url;
}
/**
* @callback ViewFunction
* @param {String} regexMatchedParams - matched regex params.
* @param {String} searchQuery - search query parameters converted to object form.
* @description
* View callback function.
* Parameters passed will be the regex matches and last parameter will be searchQueryParameters.
* In case of more than 1 path regex match, ViewFunction will be called back with (match1, match2, ..., searchQuery)
* */
/**
* @function
* @static
* @param {String} pattern - URL Pattern that is to be registered.
* @param {String} view_name - Name of the view that is mapped to this URL.
* @param {ViewFunction|Adhara} fn - View function that will be called when the pattern matches window URL.
* @param {Object} meta - Route meta which can be accessible for current route. This can be used for miscellaneous operations like finding which tab to highlight on a sidebar.
routes * */
static register_one(pattern, view_name, fn, meta){
let path_param_keys = [];
let regex = /{{([a-zA-Z$_][a-zA-Z0-9$_]*)}}/g;
let match = regex.exec(pattern);
while (match != null) {
path_param_keys.push(match[1]);
match = regex.exec(pattern);
}
pattern = pattern.replace(new RegExp("\\{\\{[a-zA-Z$_][a-zA-Z0-9$_]*\\}\\}", "g"), '');
//Removing all {{XYZ}} iff, XYZ matches first character as an alphabet ot $ or _
pattern = "^"+this.transformURL(pattern.substring(1));
pattern = pattern.replace(/[?]/g, '\\?'); //Converting ? to \? as RegExp(pattern) dosen't handle that
meta = meta || {};
registeredUrlPatterns[pattern] = { view_name, fn, path_param_keys, meta };
}
/**
* @function
* @static
* @param {RegExp} regex - URL Pattern that is to be unregistered.
* @description
* Once unregistered, this URL regex will be removed from registered URL patterns and on hitting that URL,
* will be routed to a 404.
* */
static deRegister(regex){
registeredUrlPatterns[regex] = undefined;
}
/**
* Bulk url conf
* @typedef {Object} RouterURLConf
* @property {string} url - url regex
* @property {String} view_name - view name
* @property {Object} path_params - Path params
* @property {Function|class} view - view
* */
/**
* @function
* @static
* @param {Array<RouterURLConf>} list - list of url configurations
* @description
* Register's urls in the list iteratively...
* */
static register(list){
for(let conf of list){
this.register_one(conf.url, conf.view_name, conf.view, conf.meta);
}
}
/**
* @typedef {Object} AdharaRouterConfiguration - Adhara router configuration
* @property {Array<RouterURLConf>} routes - Route configurations
* @property {String} base_uri - base url after which the matches will be taken care of.
* For example, if '/ui' is given as base URL, then /ui/home will match a route regex with '/home' only
* @property {Object<String, Function>} on_route_listeners - On Route listeners
* @property {Array<AdharaRouterMiddleware>} middlewares - Middleware functions
* */
/**
* @function
* @static
* @param {AdharaRouterConfiguration} router_configuration
* */
static configure(router_configuration){
if(router_configuration.base_uri){
baseURI = router_configuration.base_uri;
}
if(router_configuration.app_name){
appName = router_configuration.app_name;
}
if(router_configuration.routes){
Router.register(router_configuration.routes);
}
if(router_configuration.on_route_listeners){
for(let listener_name in router_configuration.on_route_listeners){
if(router_configuration.on_route_listeners.hasOwnProperty(listener_name)){
Router.onRoute(listener_name, router_configuration.on_route_listeners[listener_name]);
}
}
}
if(router_configuration.middlewares){
for(let middleware_fn of router_configuration.middlewares){
Router.registerMiddleware(middleware_fn);
}
}
}
/**
* @function
* @static
* @param {String} listener_name - A unique name for the listener that will be useful for unregistering.
* @param {Function} listener_fn - Listener function that to be called on routing. No arguments passed.
* @description
* All registered listeners will be called whenever routing happens via AdharaRouter
* */
static onRoute(listener_name, listener_fn){
listeners[listener_name] = listener_fn;
}
/**
* @function
* @static
* @param {String} listener_name - A unique name for the listener that will be useful for unregistering.
* @description
* De-reigsters the listener registered with this name.
* */
static offRouteListener(listener_name){
listeners[listener_name] = undefined;
}
/**
* @function
* @static
* @param {Function} middleware_fn - middleware function to be used
* @description Stores all middleware's and calls then in the order of registration
* */
static registerMiddleware(middleware_fn){
if(typeof middleware_fn === "function"){
middlewares.push(middleware_fn);
}
}
/**
* @function
* @static
* @description
* Routes to the current URL.
* Looks up in the registered URL patterns for a match to current URL.
* If match found, view function will be called with regex path matches in the matched order and query param's as the last argument.
* Current view name will be set to the view name configured against view URL.
* */
static route(){
if(!settingURL){
let matchFound = matchAndCall();
if(!matchFound){
throw new Error('No route registered for this url : '+getPathName());
}
}else{
settingURL = false;
}
loop(listeners, function(listener_name, listener_fn){ call_fn(listener_fn); });
}
/**
* @function
* @static
* @returns {RouterURLConf} Current view name.
* */
static getCurrentRoute(){
if(!currentRoute){
currentRoute = matchUrl().opts;
}
return currentRoute;
}
/**
* @function
* @static
* @returns {String} Current view name.
* */
static getCurrentPageName(){
return AdharaRouter.getCurrentRoute().view_name;
}
/**
* @function
* @static
* @returns {String} Current URL.
* */
static getCurrentURL(){
return getFullPath();
}
/**
* @function
* @static
* @returns {String} Current Path.
* */
static getCurrentPath(){
return getPathName();
}
/**
* @function
* @static
* @returns {String} Current URL's Hash.
* */
static getCurrentHash(){
return getHash();
}
/**
* @function
* @private
* @param {URL|String} url - URL to be navigated to.
* @description
* navigates to the provided URL.
* */
static go(url){
if (isCurrentPath(url)){
return false;
}
history.pushState({[STATE_KEY]:true}, parent.document.title, url);
//considering the behaviour of immediate state change
let state = history.state;
updateHistoryStack();
pathParams = {};
if(state !== undefined && state !== ''){
// let data = state.data;
if(state[STATE_KEY] === true || state.data[STATE_KEY] === true){
this.route();
}
}
// History.pushState({[STATE_KEY]:true}, parent.document.title, url);
return true;
}
/**
* @function
* @static
* @param {String} url - URL that to be updated.
* @description Updates current URL in the path but doesn't call the view function. Appends current URL in History.
* */
static setURL(url){
url = this.transformURL(url);
settingURL = true;
let gone = this.go(url);
if(!gone){
settingURL = false;
}
}
/**
* @function
* @static
* @param {!String} url - URL that to be updated.
* @description Replaces current URL in the path, and calls view function. No modifications made to History.
* */
static overrideURL(url){
url = this.transformURL(url);
history.replaceState({[STATE_KEY]:true}, parent.document.title, url);
// History.replaceState({[STATE_KEY]:true}, parent.document.title, url);
}
/**
* @function
* @static
* @param {!String} url - URL that to be updated.
* @description Replaces current URL in the path but doesn't call the view function. No modifications made to History.
* */
static updateURL(url){
settingURL = true;
this.overrideURL(url);
}
/**
* @function
* @static
* @param {!String} url - URL to be navigated to.
* @param {Boolean} [force=false] - If true, will call the view function even if current URL is same as provided URL.
* @description Navigates to the provided URL.
* And if provided URL is same as current URL, view function will not be called unless force parameter is true.
* */
static navigateTo(url, force){
url = this.transformURL(url);
if (!isCurrentPath(url)) {
let gone = this.go(url);
if(gone){
fetchQueryParams();
}
} else if(force) {
this.route();
}
}
/**
* @function
* @static
* @param {!String} backwardURL - backward URL to be navigated to.
* @description Navigates to the backward URL by moving back in history.
* In case if history is not available, Router will navigate to the backwardURL as if it is a new URL.
* */
static goBack(backwardURL){
if(!backwardURL){
backwardURL = historyStack.slice(-1)[0];
if(!backwardURL){
return false;
}
}
backwardURL =this.transformURL(backwardURL);
let backwardIndex = historyStack.lastIndexOf(backwardURL);
if(backwardIndex === -1){
this.navigateTo(backwardURL);
}else{
let stackLen = historyStack.length;
let negativeIndex = backwardIndex-stackLen;
historyStack.splice(stackLen+negativeIndex, -negativeIndex);
history.go(negativeIndex);
}
}
/**
* @function
* @static
* @returns {Array<String>} List of visited URL's that are routed by Router.
* */
static peekStack(){
return historyStack.slice();
}
/**
* @function
* @static
* @param {String} param_name - name of the query parameter for which value is required.
* @returns {String} Value of the search parameter from current URL.
* */
static getQueryParam(param_name){
updateParams();
return queryParams[param_name];
}
static getPathParam(param_name){
return pathParams[param_name];
}
/**
* Route type
* @typedef {String} RouteType
* @see {@link Router.RouteTypes}
* */
/**
* @function
* @static
* @param {!Object} new_params - Search Parameters to be added/updated.
* @param {Boolean} [drop_existing=false] - Whether to completely replace existing search parameters or update with the new ones.
* @param {RouteType} [route_type=this.routeTypes.NAVIGATE] - How to route. {@link RouteType}
* @param {Object} route_options - Route options.
* @param {boolean} [route_options.force=false] - Whether to force navigate URL or not. Will call view function even if current URL is same. Applies for {@link RouteType.NAVIGATE}.
* @description
* Updates the query parameters and navigates to the new URL based on route_type that is constructed with the new parameters.
* */
static updateQueryParams(new_params, drop_existing, route_type, route_options){
if(!route_type){route_type = this.RouteTypes.NAVIGATE;}
/*if(!Object.keys(new_params).length){ return; }*/
if(drop_existing){
queryParams = new_params;
}else{
Object.assign(queryParams, new_params);
}
_route(route_type, route_options);
}
/**
* @function
* @static
* @param {!Array} param_keys - Search Parameters to be dropped.
* @param {RouteType} [route_type=this.routeTypes.NAVIGATE] - How to route. {@link RouteType}
* @param {Object} route_options - Route options.
* @param {boolean} [route_options.force=false] - Whether to force navigate URL or not. Will call view function even if current URL is same. Applies for {@link RouteType.NAVIGATE}.
* @description
* Drops provided keys if exist, updates the query parameters and navigates to the new URL that is constructed with the new parameters.
* */
static removeQueryParams(param_keys, route_type, route_options){
loop(param_keys, function(idx, key){
delete queryParams[key];
});
_route(route_type, route_options);
}
/**
* @function
* @static
* @param {String} view_name - View name with which a URL pattern is registered.
* @returns {RegExp} Pattern for which the provided view name is the view name.
* */
static getURLPatternByPageName(view_name){
let matched_pattern = null;
loop(registeredUrlPatterns, function(pattern, options){
if(options.view_name === view_name){
matched_pattern = pattern;
return false;
}
});
return matched_pattern;
}
}
//---------------------
AdharaRouter = Router;
/**
* @description
* setting current view name to undefined on moving to some other page that is not registered with router.
* */
window.onpopstate = (e) => {
// if(e.state[STATE_KEY]){
AdharaRouter.route();
// }else{
// currentRoute = null;
// }
/*if(currentRoute){
let url_pattern = Router.getURLPatternByPageName(currentRoute.view_name);
if(!(url_pattern && new RegExp(url_pattern).test(getPathName()))){
currentRoute = undefined;
}
}*/
};
/**
* @static
* @readonly
* @enum {RouteType}
* @description Operations that can be performed on the entity
* */
AdharaRouter.RouteTypes = Object.freeze({
/**
* navigate to the url and call the view function. This will add current URL to History.
* */
NAVIGATE : "navigate",
/**
* override the current url and call the view function. This will not change History.
* */
OVERRIDE : "override",
/**
* Just update the URL. Adds current URL to History. View function will not be called.
* */
SET : "set",
/**
* Overrides the current URL. No modifications made to History. View function will not be called.
* */
UPDATE : "update"
});
AdharaRouter.enableAllAnchors = true;
AdharaRouter.listen = function(){
/**
* Listening to elements with route property in DOM.
* If it has href attribute, preventing default event and proceeding with SdpRouting
* */
function getRoutingElement(event){
return (event.target.nodeName !== "A")?event.currentTarget:event.target;
}
function hasAttribute(elem, attribute_name) {
return elem.hasAttribute(attribute_name);
}
function routeHandler(event){
let re = getRoutingElement(event);
if ((AdharaRouter.enableAllAnchors || hasAttribute(re, "route")) && hasAttribute(re, "href") && !hasAttribute(re, "skiprouting")) {
let target = this.getAttribute("target");
let miniURL = this.getAttribute('href').trim();
let isHash = miniURL.indexOf("#") === 0;
if( ( target && target !== "_self") || miniURL.indexOf('javascript') !== -1 || isHash ){
return;
}
let url = miniURL;//this.href;
try{
let go_to_url = new URL(url);
if(go_to_url.host !== window.location.host){
return;
}
}catch(e){/*Do nothing. Try catches the invalid url on new ULR(...)*/}
let go_back = this.getAttribute("data-back");
let force = this.getAttribute("data-force") !== "false";
if (go_back) {
return AdharaRouter.goBack(url);
}
AdharaRouter.navigateTo(url, force);
if (url) {
event.preventDefault();
event.stopPropagation();
}
}
}
jQuery(document).on("click", "a", routeHandler);
jQuery(document).on("click", "[route]", routeHandler);
/*document.addEventListener('click', function (e) {
if(e.target.nodeName === "A" && ( AdharaRouter.enableAllAnchors || hasAttribute(e.target, "route") ) ){
let url = e.target.getAttribute('href').trim();
if(url.indexOf('javascript') !== -1){return;}
if(url){
e.preventDefault();
e.stopPropagation();
}
let go_back = e.target.getAttribute("data-back");
let force = e.target.getAttribute("data-force") !== "false";
if(go_back){ return AdharaRouter.goBack(url); }
AdharaRouter.navigateTo(url, force);
}
}, false);*/
};
//---------------------
updateHistoryStack(); //updating history stack with the entry URL
})();