/**
____ _____ __ __ ____ ____ ____
( _ \ ( _ )( )( )(_ _)( ___)( _ \
) _/ )(_)( )(__)( )( )__) ) /
(_)\_) (_____)(______) (__) (____)(_)\_) 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);