1 /**
  2  * @fileOverview simple router to get path parameter and query parameter from hash(old ie) or url(html5)
  3  * @author yiminghe@gmail.com
  4  */
  5 KISSY.add('mvc/router', function (S, Event, Base) {
  6     var queryReg = /\?(.*)/,
  7         each = S.each,
  8         // take a breath to avoid duplicate hashchange
  9         BREATH_INTERVAL = 100,
 10         grammar = /(:([\w\d]+))|(\\\*([\w\d]+))/g,
 11         // all registered route instance
 12         allRoutes = [],
 13         win = S.Env.host,
 14         history = win.history ,
 15         supportNativeHistory = !!(history && history['pushState']),
 16         ROUTER_MAP = "__routerMap";
 17 
 18     function findFirstCaptureGroupIndex(regStr) {
 19         var r, i;
 20         for (i = 0; i < regStr.length; i++) {
 21             r = regStr.charAt(i);
 22             // skip escaped reg meta char
 23             if (r == "\\") {
 24                 i++;
 25             } else if (r == "(") {
 26                 return i;
 27             }
 28         }
 29         throw new Error("impossible to not to get capture group in kissy mvc route");
 30     }
 31 
 32     function getHash() {
 33         // 不能 location.hash
 34         // http://xx.com/#yy?z=1
 35         // ie6 => location.hash = #yy
 36         // 其他浏览器 => location.hash = #yy?z=1
 37         return location.href.replace(/^[^#]*#?!?(.*)$/, '$1');
 38     }
 39 
 40     /**
 41      * get url fragment and dispatch
 42      */
 43     function getFragment() {
 44         if (Router.nativeHistory && supportNativeHistory) {
 45             return location.pathname.substr(Router.urlRoot.length) + location.search;
 46         } else {
 47             return getHash();
 48         }
 49     }
 50 
 51     /**
 52      * slash ------------- start
 53      */
 54 
 55     /**
 56      * whether string end with slash
 57      * @param str
 58      */
 59     function endWithSlash(str) {
 60         return S.endsWith(str, "/");
 61     }
 62 
 63     function startWithSlash(str) {
 64         return S.startsWith(str, "/");
 65     }
 66 
 67     function removeEndSlash(str) {
 68         if (endWithSlash(str)) {
 69             str = str.substring(0, str.length - 1);
 70         }
 71         return str;
 72     }
 73 
 74     function removeStartSlash(str) {
 75         if (startWithSlash(str)) {
 76             str = str.substring(1);
 77         }
 78         return str;
 79     }
 80 
 81     function addEndSlash(str) {
 82         return removeEndSlash(str) + "/";
 83     }
 84 
 85     function addStartSlash(str) {
 86         return "/" + removeStartSlash(str);
 87     }
 88 
 89     function equalsIgnoreSlash(str1, str2) {
 90         str1 = removeEndSlash(str1);
 91         str2 = removeEndSlash(str2);
 92         return str1 == str2;
 93     }
 94 
 95     /**
 96      * slash ------------------  end
 97      */
 98 
 99     /**
100      * get full path from fragment for html history
101      * @param fragment
102      */
103     function getFullPath(fragment) {
104         return location.protocol + "//" + location.host +
105             removeEndSlash(Router.urlRoot) + addStartSlash(fragment)
106     }
107 
108     /**
109      * get query object from query string
110      * @param path
111      */
112     function getQuery(path) {
113         var m,
114             ret = {};
115         if (m = path.match(queryReg)) {
116             return S.unparam(m[1]);
117         }
118         return ret;
119     }
120 
121     /**
122      * match url with route intelligently (always get optimal result)
123      */
124     function dispatch() {
125         var path = getFragment(),
126             fullPath = path,
127             query,
128             arg,
129             finalRoute = 0,
130             finalMatchLength = -1,
131             finalRegStr = "",
132             finalFirstCaptureGroupIndex = -1,
133             finalCallback = 0,
134             finalRouteName = "",
135             finalParam = 0;
136 
137         path = fullPath.replace(queryReg, "");
138         // user input : /xx/yy/zz
139         each(allRoutes, function (route) {
140             var routeRegs = route[ROUTER_MAP],
141                 // match exactly
142                 exactlyMatch = 0;
143             each(routeRegs, function (desc) {
144                     var reg = desc.reg,
145                         regStr = desc.regStr,
146                         paramNames = desc.paramNames,
147                         firstCaptureGroupIndex = -1,
148                         m,
149                         name = desc.name,
150                         callback = desc.callback;
151                     if (m = path.match(reg)) {
152                         // match all result item shift out
153                         m.shift();
154 
155                         function genParam() {
156                             if (paramNames) {
157                                 var params = {};
158                                 each(m, function (sm, i) {
159                                     params[paramNames[i]] = sm;
160                                 });
161                                 return params;
162                             } else {
163                                 // if user gave directly reg
164                                 // then call callback with match result array
165                                 return [].concat(m);
166                             }
167                         }
168 
169                         function upToFinal() {
170                             finalRegStr = regStr;
171                             finalFirstCaptureGroupIndex = firstCaptureGroupIndex;
172                             finalCallback = callback;
173                             finalParam = genParam();
174                             finalRoute = route;
175                             finalRouteName = name;
176                             finalMatchLength = m.length;
177                         }
178 
179                         // route: /xx/yy/zz
180                         if (!m.length) {
181                             upToFinal();
182                             exactlyMatch = 1;
183                             return false;
184                         }
185                         else if (regStr) {
186 
187                             firstCaptureGroupIndex = findFirstCaptureGroupIndex(regStr);
188 
189                             // final route : /*
190                             // now route : /xx/*
191                             if (firstCaptureGroupIndex > finalFirstCaptureGroupIndex) {
192                                 upToFinal();
193                             }
194 
195                             else if (
196                                 firstCaptureGroupIndex == finalFirstCaptureGroupIndex &&
197                                     finalMatchLength >= m.length
198                                 ) {
199                                 // final route : /xx/:id/:id
200                                 // now route :  /xx/:id/zz
201                                 if (m.length < finalMatchLength) {
202                                     upToFinal()
203                                 } else if (regStr.length > finalRegStr.length) {
204                                     upToFinal();
205                                 }
206                             }
207 
208                             // first route has priority
209                             else if (!finalRoute) {
210                                 upToFinal();
211                             }
212                         }
213                         // if exists user-given reg router rule then update value directly
214                         // first route has priority
215                         // 用户设置的正则表达式具备高优先级
216                         else {
217                             upToFinal();
218                             exactlyMatch=1;
219                             return false;
220                         }
221                     }
222                 }
223             );
224 
225             if (exactlyMatch) {
226                 return false;
227             }
228         });
229 
230 
231         if (finalParam) {
232             query = getQuery(fullPath);
233             finalCallback.apply(finalRoute, [finalParam, query]);
234             arg = {
235                 name:finalRouteName,
236                 paths:finalParam,
237                 query:query
238             };
239             finalRoute.fire('route:' + finalRouteName, arg);
240             finalRoute.fire('route', arg);
241         }
242     }
243 
244     /**
245      * transform route declaration to router reg
246      * @param str
247      *         /search/:q
248      *         /user/*path
249      */
250     function transformRouterReg(self, str, callback) {
251         var name = str,
252             paramNames = [];
253 
254         if (S.isFunction(callback)) {
255             // escape keyword from regexp
256             str = S.escapeRegExp(str);
257 
258             str = str.replace(grammar, function (m, g1, g2, g3, g4) {
259                 paramNames.push(g2 || g4);
260                 // :name
261                 if (g2) {
262                     return "([^/]+)";
263                 }
264                 // *name
265                 else if (g4) {
266                     return "(.*)";
267                 }
268             });
269 
270             return {
271                 name:name,
272                 paramNames:paramNames,
273                 reg:new RegExp("^" + str + "$"),
274                 regStr:str,
275                 callback:callback
276             };
277         } else {
278             return {
279                 name:name,
280                 reg:callback.reg,
281                 callback:normFn(self, callback.callback)
282             };
283         }
284     }
285 
286     /**
287      * normalize function by self
288      * @param self
289      * @param callback
290      */
291     function normFn(self, callback) {
292         if (S.isFunction(callback)) {
293             return callback;
294         } else if (S.isString(callback)) {
295             return self[callback];
296         }
297         return callback;
298     }
299 
300     function _afterRoutesChange(e) {
301         var self = this;
302         self[ROUTER_MAP] = {};
303         self.addRoutes(e.newVal);
304     }
305 
306     /**
307      * @name Router
308      * @class
309      * Router used to route url to responding action callbacks.
310      * @memberOf MVC
311      * @extends Base
312      */
313     function Router() {
314         var self = this;
315         Router.superclass.constructor.apply(self, arguments);
316         self.on("afterRoutesChange", _afterRoutesChange, self);
317         _afterRoutesChange.call(self, {newVal:self.get("routes")});
318         allRoutes.push(self);
319     }
320 
321     Router.ATTRS =
322     /**
323      * @lends MVC.Router#
324      */
325     {
326         /**
327          * Route and action config.
328          * @type Object
329          * @example
330          * <code>
331          *   {
332          *     "/search/:param":"callback"
333          *     // or
334          *     "search":{
335          *       reg:/xx/,
336          *       callback:fn
337          *     }
338          *   }
339          * </code>
340          */
341         routes:{}
342     };
343 
344     S.extend(Router, Base,
345         /**
346          * @lends MVC.Router#
347          */
348         {
349             /**
350              * Add config to current router.
351              * @param {Object} routes Route config.
352              * @example
353              * <code>
354              *   {
355              *     "/search/:param":"callback"
356              *     // or
357              *     "search":{
358              *       reg:/xx/,
359              *       callback:fn
360              *     }
361              *   }
362              * </code>
363              */
364             addRoutes:function (routes) {
365                 var self = this;
366                 each(routes, function (callback, name) {
367                     self[ROUTER_MAP][name] = transformRouterReg(self, name, normFn(self, callback));
368                 });
369             }
370         },
371         /**
372          * @lends MVC.Router
373          */
374         {
375 
376             /**
377              * Navigate to specified path.
378              * @param {String} path Destination path.
379              * @param {Object} [opts] Config for current navigation.
380              * @param {Boolean} opts.triggerRoute Whether to trigger responding action
381              *                  even current path is same as parameter
382              */
383             navigate:function (path, opts) {
384                 if (getFragment() !== path) {
385                     if (Router.nativeHistory && supportNativeHistory) {
386                         history['pushState']({}, "", getFullPath(path));
387                         // pushState does not fire popstate event (unlike hashchange)
388                         // so popstate is not statechange
389                         // fire manually
390                         dispatch();
391                     } else {
392                         location.hash = "!" + path;
393                     }
394                 } else if (opts && opts.triggerRoute) {
395                     dispatch();
396                 }
397             },
398             /**
399              * Start router (url monitor).
400              * @param {object} opts
401              * @param {Function} opts.success Callback function to be called after router is started.
402              * @param {String} opts.urlRoot Specify url root for html5 history management.
403              * @param {Boolean} opts.nativeHistory Whether enable html5 history management.
404              */
405             start:function (opts) {
406 
407                 if (Router.__started) {
408                     return;
409                 }
410 
411                 opts = opts || {};
412 
413                 opts.urlRoot = opts.urlRoot || "";
414 
415                 var urlRoot,
416                     nativeHistory = opts.nativeHistory,
417                     locPath = location.pathname,
418                     hash = getFragment(),
419                     hashIsValid = location.hash.match(/#!.+/);
420 
421                 urlRoot = Router.urlRoot = opts.urlRoot;
422                 Router.nativeHistory = nativeHistory;
423 
424                 if (nativeHistory) {
425 
426                     if (supportNativeHistory) {
427                         // http://x.com/#!/x/y
428                         // =>
429                         // http://x.com/x/y
430                         // =>
431                         // process without refresh page and add history entry
432                         if (hashIsValid) {
433                             if (equalsIgnoreSlash(locPath, urlRoot)) {
434                                 // put hash to path
435                                 history['replaceState']({}, "", getFullPath(hash));
436                                 opts.triggerRoute = 1;
437                             } else {
438                                 S.error("location path must be same with urlRoot!");
439                             }
440                         }
441                     }
442                     // http://x.com/x/y
443                     // =>
444                     // http://x.com/#!/x/y
445                     // =>
446                     // refresh page without add history entry
447                     else if (!equalsIgnoreSlash(locPath, urlRoot)) {
448                         location.replace(addEndSlash(urlRoot) + "#!" + hash);
449                         return;
450                     }
451 
452                 }
453 
454                 // prevent hashChange trigger on start
455                 setTimeout(function () {
456                     if (nativeHistory && supportNativeHistory) {
457                         Event.on(win, 'popstate', dispatch);
458                     } else {
459                         Event.on(win, "hashchange", dispatch);
460                         opts.triggerRoute = 1;
461                     }
462 
463                     // check initial hash on start
464                     // in case server does not render initial state correctly
465                     // when monitor hashchange ,client must be responsible for dispatching and rendering.
466                     if (opts.triggerRoute) {
467                         dispatch();
468                     }
469                     opts.success && opts.success();
470 
471                 }, BREATH_INTERVAL);
472 
473                 Router.__started = 1;
474             }
475         });
476 
477     return Router;
478 
479 }, {
480     requires:['event', 'base']
481 });
482 
483 /**
484  * 2011-11-30
485  *  - support user-given native regexp for router rule
486  *
487  * refer :
488  * http://www.w3.org/TR/html5/history.html
489  * http://documentcloud.github.com/backbone/
490  **/