/** ____ _____ __ __ ____ ____ ____ ( _ \ ( _ )( )( )(_ _)( ___)( _ \ ) _/ )(_)( )(__)( )( )__) ) / (_)\_) (_____)(______) (__) (____)(_)\_) for SPARC By Mike & Nike * This file is part of Sparc by Mike & Nike. * Sparc is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License, * as published by the Free Software Foundation, * either version 3 of the License, or (at your option) any later version. * Sparc is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * See the GNU General Public License for more details. * Get your copy of the GNU General Public License at . * This class contains the SPA router mechanics.
* Once instanciated with a set of top-level routes, just launch instance.route().
* * @category Core * @subcategory Libraries */ class Router { /** * @typedef RouteNode * @property {string} url * @property {string} role * @property {string} controller * @property {string} [method] */ /** * * @param {object} args - object defining top-level routes * @param {object} args.routes - object defining top-level routes * @param {string|function} args.role - user role, used for selecting most apropriate route (if function: called at each re-routing) * @param {function} args.callback - Called when everything a route has been executed * @param {string} args.origin - [Experimental] - when router is not master of the domain root : process only URLs that begin with it. * @param {string} args.controllersPath - Location of application's scripts * @param {object} args.defaults - default values */ constructor(args) { this.defaults = args.defaults; if(args.hasOwnProperty('controllersPath') && (args.controllersPath!='')) this.defaults.controllersPath = args.controllersPath; if(args.hasOwnProperty('modelsPath') && (args.modelsPath!='')) this.defaults.modelsPath = args.modelsPath; if(args.hasOwnProperty('viewsPath') && (args.viewsPath!='')) this.defaults.viewsPath = args.viewsPath; this.defaults.origin = args.hasOwnProperty('origin') && (args.origin!='') ? args.origin : (new URL(document.location)).origin; this.ControllerInstances = {}; this.ControllerConfigs = {}; this.httpErrorRoute = null; this.routes = this.cleanupRoutes(args.routes); this.roles = args.roles; this.callback = args.callback; this.routeReady = false; this.merged_ctrl_routes = []; this.currentRoute = null; document.addEventListener('click', this.onGlobalClick.bind(this)); window.addEventListener('popstate', this.bckFwdInterceptor.bind(this)); } onGlobalClick(event) { let target = event.target; let notfound = true; while(target && target.tagName && (target.tagName.toUpperCase() != 'BODY') && notfound){ if(target.tagName.toUpperCase() == 'A'){ notfound = false; this.linksInterceptor(target, event); } else target = target.parentNode; } } bckFwdInterceptor(e) { this.route(); } linksInterceptor(target, e){ if(target.hasAttribute('noroute')) return if(target.href.substr(0,this.defaults.origin.length) == this.defaults.origin) { //internal e.preventDefault() history.pushState({}, '', document.location) history.replaceState(null, '', this.cleanupUrl(target.href).pathname) this.route() } } cleanupUrl(target) { let url = new URL(target || document.location, this.defaults.origin); url.pathname = url.pathname.replace(/\/+/g,'/'); return(url); } route(forcedUrl, forcedParams) { if(forcedUrl) history.replaceState(null, null, forcedUrl); let routeinfo = this.getRoute(forcedUrl, forcedParams || {}); let [currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl] = routeinfo if(exturl && exturl.match(/\w{2,6}:\/\//)) { if(params) exturl += '?' +(new URLSearchParams(params).toString()); document.location = exturl; this.routeReady = true; return; } else if(exturl && (exturl != '')) { // Internal redirect history.replaceState(null, '', exturl); this.route(); } else { this.execRoute(...routeinfo); } } execRoute(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl){ this.routeReady = false; // Start by making sure we have the MasterController (aka "mc") loaded, if not, first, load it! let mcPath = app.config.router.controllersPath; let mcLoader; if(!(app.config.router.masterController in app.LoadedClasses)){ mcLoader = app.LoadedClasses.Assets.loadJson({ 'path': mcPath, 'name': app.config.router.masterController+'.json', }).then((tcConfig) => { if(typeof(tcConfig)!='undefined') { tcConfig.routes=[]; // no subroutes for the masterController (use baseRoutes instead) // Avoid controllerConfigLoaded (made for normal ctrl, but merge config directly) this.mergeControllerConfig(currentRoute, mcPath, app.config.router.masterController, null, {} , tcConfig); // return the autoLoadController Promise, so the "then" below depends on ctrl loaded (not config loaded) return(this.autoLoadController(currentRoute, mcPath, app.config.router.masterController, null, {})); } else { console.error('Problem loading Master-Controller ! (could not load its config)'); return(new Promise((resolve,fail) => { fail(); })); } }); } else { mcLoader = new Promise((resolve) => { resolve(); }) } mcLoader.then( () => { // Get controller config file (add controllersPath only to json, for the script it is done by getRoute) app.LoadedClasses.Assets.loadJson({ 'path': ctrlPath, 'name': ctrlClass+'.json', }) .then( (config) => { if(typeof(config)!='undefined') { this.controllerConfigLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl, config); } else { let err = 'Could not load configuration of controller '+ctrlClass+' !!'; this.redirectToHttpErrorRoute(err); } }); }); } controllerConfigLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, exturl, config) { config = config || { // Cool attitude if config not found or not clean json routes:[], dependencies:[], AssetsDependencies:{} }; this.mergeControllerConfig(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, config); // Recompute route now that there are some sub-routes, if something changed we need to recurse // If nothing changed, then we're good to load, instanciate & call ! let newCurrentRoute, newCtrlPath, newCtrlClass, newCtrlMethod, newParams; [newCurrentRoute, newCtrlPath, newCtrlClass, newCtrlMethod, newParams, exturl] = this.getRoute(currentRoute.forcedUrl, currentRoute.forcedParams); if(exturl) { //Do not recur if we stumbbled on a exturl, just go to it this.route(exturl, null) } else if(newCurrentRoute.url!=currentRoute.url){ this.execRoute(newCurrentRoute, newCtrlPath, newCtrlClass, newCtrlMethod, newParams, exturl); } else { // the else makes sure we autoLoad only at the bottom-level of the recursion if(typeof(this.ControllerInstances[ctrlClass])=='object') { // Non-persistent controllers were once were here, but not usefull, removed this.launchControllerMethod(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params); } else { // Unknown ctrl : load it ! this.autoLoadController(newCurrentRoute, newCtrlPath, newCtrlClass, newCtrlMethod, newParams); } } } mergeControllerConfig(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params, config){ this.ControllerConfigs[ctrlClass] = config // Carefull not to remerge twice the same config (otherwise url concats will go bzurg) if(this.merged_ctrl_routes.indexOf(ctrlClass)>-1) return; this.merged_ctrl_routes.push(ctrlClass); // Add sub routes, except for masterctrl if((ctrlClass!= app.config.router.masterController) || ('routes' in config)){ let newroutes = this.cleanupRoutes(config.routes); for(let newroute of newroutes){ if(currentRoute.url=='!defaultroute') continue; let fullurl = (currentRoute.url+'/'+newroute.url).replace(/\/+/g,'/'); newroute.url = fullurl; this.routes.push(newroute); } } if(!Loader.Dependencies.hasOwnProperty(ctrlClass)) Loader.Dependencies[ctrlClass] = []; // Add controller dependencies (not views, not) if('controllerDependencies' in config){ for(var script of config.controllerDependencies){ if(script.substring(0,4)!='http') Loader.Dependencies[ctrlClass].push(('/app/'+script).replace(/\/+/g,'/')) else Loader.Dependencies[ctrlClass].push(script) } } // add models as controller dependencies with adapted path if('models' in config){ for(var dep of config.models){ if(typeof(dep) == 'string') {// just add model as dependency of controller, with correct path let model = (this.defaults.modelsPath+'/'+dep).replace(/\/+/g,'/'); Loader.Dependencies[ctrlClass].push(model); } else if(typeof(dep) == 'object'){ // This model depends on other models let model = (this.defaults.modelsPath+'/'+dep.model).replace(/\/+/g,'/'); // add model as dependency of controller Loader.Dependencies[ctrlClass].push(model); // add sub-dependencies without dups & with correct path if(!Loader.Dependencies.hasOwnProperty(model)) Loader.Dependencies[model] = []; for(let submodel of dep.dependencies){ if(Loader.Dependencies[model].indexOf(submodel)<0) { Loader.Dependencies[model].push((this.defaults.modelsPath+'/'+submodel).replace(/\/+/g,'/')); } } } } } // add views as controller dependencies with adapted path if('views' in config){ for(var dep of config.views){ if(typeof(dep) == 'string') {// just add view as dependency of controller, with correct path let view = (this.defaults.viewsPath+'/'+dep).replace(/\/+/g,'/'); Loader.Dependencies[ctrlClass].push(view); } else if(typeof(dep) == 'object'){ // This view depends on other views let view = (this.defaults.viewsPath+'/'+dep.view).replace(/\/+/g,'/'); // add view as dependency of controller Loader.Dependencies[ctrlClass].push(view); // add sub-dependencies without dups & with correct path if(!Loader.Dependencies.hasOwnProperty(view)) Loader.Dependencies[view] = []; for(let subview of dep.dependencies){ if(Loader.Dependencies[view].indexOf(subview)<0) { Loader.Dependencies[view].push((this.defaults.viewsPath+'/'+subview).replace(/\/+/g,'/')); } } } } } // Remove dups (maybe there was existing stuff before we got here) Loader.Dependencies[ctrlClass] = Array.from(new Set(Loader.Dependencies[ctrlClass])); Loader.AssetsDependencies[ctrlClass] = config.assets; } autoLoadController(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params){ let scriptFp = (ctrlPath+'/'+ctrlClass).replace(/\/+/g,'/'); let deps = {}; deps[scriptFp] = Loader.Dependencies[ctrlClass]; return ( Loader.loadScripts({ 'scripts':[scriptFp], 'dependencies':deps, }).then( () => { this.controllerLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params) }, (error) => { // Loading of the route failed => Try to reroute to defaultroute let err = 'Loading of the class "'+ctrlClass+'" Failed !'; console.error(err); if(currentRoute != this.httpErrorRoute) this.redirectToHttpErrorRoute(err); this.routeReady = true; } ) ); } registerController(ctrlClass){ try { this.ControllerInstances[ctrlClass] = new app.LoadedClasses[ctrlClass](); } catch(e) { console.error(`"${e}" instantiating controller ${ctrlClass} `); } ; } controllerLoaded(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params){ this.registerController(ctrlClass); if(ctrlClass != app.config.router.masterController) { let mTemplate if('template' in currentRoute) mTemplate = currentRoute.template; else mTemplate = app.config.router.defaultMasterTemplate; // Launch the useTemplate and wait for it to be ready before launching the method. this.ControllerInstances[app.config.router.masterController].useTemplate(mTemplate).then( () => { this.launchControllerMethod(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params); } ); } } launchControllerMethod(currentRoute, ctrlPath, ctrlClass, ctrlMethod, params){ if((ctrlClass in this.ControllerInstances) && (typeof(this.ControllerInstances[ctrlClass][ctrlMethod])=='function')) { this.currentRoute = currentRoute this.currentRoute.realUrl = new URL(document.location.href).pathname this.ControllerInstances[ctrlClass][ctrlMethod]({ 'currentRoute': currentRoute, 'params': params }); this.routeReady = true; if(typeof(this.callback)=='function') this.callback(ctrlClass,ctrlMethod,params); } else { let err; if(ctrlClass in this.ControllerInstances){ err = `Could not find method "${ctrlMethod}" in class "${ctrlClass}"`; } else { err = `Controller "${ctrlClass}" not instantiated !`; } console.error(err); if(currentRoute != this.httpErrorRoute) this.redirectToHttpErrorRoute(err); this.routeReady = true; } } redirectToHttpErrorRoute(err=''){ let ctrlPath = this.httpErrorRoute.controller.substr(0,this.httpErrorRoute.controller.lastIndexOf('/')+1); ctrlPath = (this.defaults.controllersPath+'/'+ctrlPath).replace(/\/+/g,'/'); let ctrlClass = this.httpErrorRoute.controller.substr(this.httpErrorRoute.controller.lastIndexOf('/')+1); let ctrlMethod =this.httpErrorRoute.method; app.LoadedClasses.Assets.loadJson({ 'path': ctrlPath, 'name': ctrlClass+'.json', }).then( (config) => { this.mergeControllerConfig(this.httpErrorRoute, ctrlPath, ctrlClass, ctrlMethod, {'error' : err}, config) this.autoLoadController(this.httpErrorRoute, ctrlPath, ctrlClass, ctrlMethod, {'error' : err}); }); } getRoute(forcedUrl, forcedParams) { let pathname = this.cleanupUrl(forcedUrl).pathname; let bestroute = {'score':0, 'route':{}, 'parts':'', 'extractedParams':{}, 'gibberish':'', idx:0 }; let idx = 0; let myRoles = (typeof(this.roles)=='function') ? this.roles() : this.roles ; for(let route of this.routes){ route['forcedUrl'] = forcedUrl; route['forcedParams'] = forcedParams; let intersect = route['role'].filter(v => (myRoles.indexOf(v)>-1)); if((route['role']!='*') && (intersect.length==0)) continue; let [score, parts, paramvals, gibberish] = this.matchUrl(pathname, route); // make sure a role specific route wins over a '*' role route (if already match, thus score>0, not to interfere with defaultroute). if((score>0) && (route['role']!='*') && (intersect.length>0)) { score++; } // Best route wins, and when exaequo, childe-route (embed) wins over a parent-route if( (score > bestroute.score) || ((score == bestroute.score) && (idx > bestroute.idx)) ) { bestroute = {'score':score, 'route':route, 'parts':parts, 'extractedParams':paramvals, 'gibberish':gibberish, idx:idx }; } idx++; } if(bestroute['score']==0){ // no match => return default route if(this.httpErrorRoute) bestroute = { 'score': 0, 'route': this.httpErrorRoute, 'parts': [], 'extractedParams': {'error' : 'No route found for this URL!'} , 'gibberish': pathname, 'idx': 0 }; else { console.error('No matching route for this url, and no default route defined!'); return([null,'', '', '', {}]); } } let ctrlPath, ctrlClass, ctrlMethod, methParams, exturl; if(bestroute.route.hasOwnProperty('controller')){ // Normal MVC route ctrlPath = bestroute.route.controller.substr(0,bestroute.route.controller.lastIndexOf('/')+1); ctrlPath = (this.defaults.controllersPath+'/'+ctrlPath).replace(/\/+/g,'/'); ctrlClass = bestroute.route.controller.substr(bestroute.route.controller.lastIndexOf('/')+1); if(bestroute.route.hasOwnProperty('method') && (bestroute.route.method!='')){ // predefined Method in config ctrlMethod =bestroute.route.method; } else { // no predefined Method, // unused stuff in url ? use last url part if(bestroute.score >= 2) ctrlMethod = bestroute.parts[bestroute.parts.length-1]; else ctrlMethod = 'index'; // nothing left in url ? default to index } if(!bestroute.route.hasOwnProperty('extractedParams')) bestroute.params = {}; methParams = {...bestroute.extractedParams, ...bestroute.route.params, ...forcedParams}; exturl = ''; } else { // External route exturl = bestroute.route.exturl; } return([bestroute.route, ctrlPath, ctrlClass, ctrlMethod, methParams, exturl]); } cleanupRoutes(routes){ const regex = /[\/:][^\/:]+/g; const paramregex = /^:(\w+)(\(.*\))?/; const pathregex = /^[-\/\w]+$/; const roleregex = /^[-\w]+$/; let cleanroutes = []; for(let route of routes){ let keepit = true; if(!route.hasOwnProperty('url')) { console.warn('Missing url in route definition, route ignored ! ',route); keepit = false; } else { if(route.url!='!defaultroute'){ let routeparts = route.url.match(regex); if(routeparts===null) routeparts = ['/']; //Happens for empty of slah-only url; (or maybe cataclysmic ones) for(let routefrag of routeparts) { if(routefrag.charAt(0)=='/'){ if(!(routefrag.match(pathregex))) { console.warn('Bad url in route definition (forbidden character in path), route ignored ! ',route); keepit = false; } } else { if(!(routefrag.match(paramregex))) { console.warn('Bad url in route definition (forbidden character in param), route ignored ! ',route); keepit = false; } } } } else { this.httpErrorRoute = route; } } if(!route.hasOwnProperty('role')) { console.warn('Missing role in route definition, route ignored ! ',route); keepit = false; } else { if(typeof(route.role)=='string') route.role = [route.role]; if(route.role!='*'){ for(let role of route.role){ // also works if string (letter by letter but ok) if(!(role.match(roleregex))) { console.warn('Bad role in route definition (forbidden character), route ignored ! ',route); keepit = false; } } } } if( (!route.hasOwnProperty('controller')) && (!route.hasOwnProperty('exturl')) ) { console.warn('Missing one of controller / exturl in route definition, route ignored ! ',route); keepit = false; } else if(route.hasOwnProperty('exturl')) { // don't touch it ! } else if(!(route.controller.match(pathregex))) { console.warn('Bad controller path in route definition (forbidden character), route ignored ! ',route); keepit = false; } if(keepit) cleanroutes.push(route); } return(cleanroutes); } matchUrl(urlpath, route){ const regex = /[\/:][^\/:]+/g; const paramregex = /:(\w+)(\(.*\))?/; if((urlpath=='/') && (route.url=='/')) return([1, '/', {}, []]); let urlpaths = urlpath.substr(1).split('/'); let nbmatch = 0; let gibberish = ''; let routeparts; routeparts = route.url.match(regex); let parts = []; let params = {}; if(!routeparts) return([0, [], {}, '']); for(let routefrag of routeparts) { if(nbmatch>(urlpaths.length-1)) { return([0, [], {}, '']); } if(routefrag.charAt(0)=='/'){ // url frag if(routefrag!='/') routefrag = routefrag.substr(1); if(routefrag!=urlpaths[nbmatch]){ return([0, [], {}, '']); } parts.push(urlpaths[nbmatch]); } else { // param if(urlpaths[nbmatch]=='') return([0, [], {}, '']); // no '//' where param expected let parampattern = routefrag.match(paramregex); if(!parampattern) { console.warn('Bad parameter syntax in routes definition : '+routefrag+'(ignored)'); } else { let [x, parname, parvalidation] = parampattern if(parvalidation){ //Validate with given regex let pvrx = RegExp('^'+parvalidation.substr(1, parvalidation.length-2)+'$'); if(urlpaths[nbmatch].match(pvrx)) params[parname] = urlpaths[nbmatch]; else return([0, [], {}, '']); // parameter with missed validation } else { params[parname] = urlpaths[nbmatch]; // take without validation } } } nbmatch++; } let pointer; // Still have stuff in url, and no predefined method, so take meth from url if( (nbmatch (myRoles.indexOf(v)>-1)); if((route.role!='*') && (intersect.length==0)) continue if(('/'+route.controller).replace(/\/+/g,'/')!=('/'+ctrl).replace(/\/+/g,'/')) continue let url=route.url; //Check if params and route params match let parok=true; let rparams = route.url.match(/:\w+/g); if(rparams){ let re; for(let rparam of rparams){ rparam=rparam.substr(1); if(!params.hasOwnProperty(rparam)) { parok=false; break; } re = RegExp(':'+rparam+'(?!\w)','g'); url = url.replace(re, '/'+params[rparam]); } } if(!parok) continue; if(!route.hasOwnProperty('method')){ url+=('/'+method).replace(/\/+/g,'/'); } return(url); } return(''); } } app.registerClass('Router', Router);