1 // ==========================================================================
  2 // Project:   SproutCore - JavaScript Application Framework
  3 // Copyright: ©2006-2011 Strobe Inc. and contributors.
  4 //            Portions ©2008-2011 Apple Inc. All rights reserved.
  5 // License:   Licensed under MIT license (see license.js)
  6 // ==========================================================================
  7 
  8 /**
  9   @class
 10 
 11   SC.routes manages the browser location. You can change the hash part of the
 12   current location. The following code
 13 
 14       SC.routes.set('location', 'notes/edit/4');
 15 
 16   will change the location to http://domain.tld/my_app#notes/edit/4. Adding
 17   routes will register a handler that will be called whenever the location
 18   changes and matches the route:
 19 
 20       SC.routes.add(':controller/:action/:id', MyApp, MyApp.route);
 21 
 22   You can pass additional parameters in the location hash that will be relayed
 23   to the route handler:
 24 
 25       SC.routes.set('location', 'notes/show/4?format=xml&language=fr');
 26 
 27   The syntax for the location hash is described in the location property
 28   documentation, and the syntax for adding handlers is described in the
 29   add method documentation.
 30 
 31   Browsers keep track of the locations in their history, so when the user
 32   presses the 'back' or 'forward' button, the location is changed, SC.route
 33   catches it and calls your handler. Except for Internet Explorer versions 7
 34   and earlier, which do not modify the history stack when the location hash
 35   changes.
 36 
 37   SC.routes also supports HTML5 history, which uses a '/' instead of a '#'
 38   in the URLs, so that all your website's URLs are consistent.
 39 */
 40 SC.routes = SC.Object.create(
 41   /** @scope SC.routes.prototype */{
 42 
 43   /**
 44     Set this property to YES if you want to use HTML5 history, if available on
 45     the browser, instead of the location hash.
 46 
 47     HTML 5 history uses the history.pushState method and the window's popstate
 48     event.
 49 
 50     By default it is NO, so your URLs will look like:
 51 
 52         http://domain.tld/my_app#notes/edit/4
 53 
 54     If set to YES and the browser supports pushState(), your URLs will look
 55     like:
 56 
 57         http://domain.tld/my_app/notes/edit/4
 58 
 59     You will also need to make sure that baseURI is properly configured, as
 60     well as your server so that your routes are properly pointing to your
 61     SproutCore application.
 62 
 63     @see http://dev.w3.org/html5/spec/history.html#the-history-interface
 64     @property
 65     @type {Boolean}
 66   */
 67   wantsHistory: NO,
 68 
 69   /**
 70     A read-only boolean indicating whether or not HTML5 history is used. Based
 71     on the value of wantsHistory and the browser's support for pushState.
 72 
 73     @see wantsHistory
 74     @property
 75     @type {Boolean}
 76   */
 77   usesHistory: null,
 78 
 79   /**
 80     The base URI used to resolve routes (which are relative URLs). Only used
 81     when usesHistory is equal to YES.
 82 
 83     The build tools automatically configure this value if you have the
 84     html5_history option activated in the Buildfile:
 85 
 86         config :my_app, :html5_history => true
 87 
 88     Alternatively, it uses by default the value of the href attribute of the
 89     <base> tag of the HTML document. For example:
 90 
 91         <base href="http://domain.tld/my_app">
 92 
 93     The value can also be customized before or during the execution of the
 94     main() method.
 95 
 96     @see http://www.w3.org/TR/html5/semantics.html#the-base-element
 97     @property
 98     @type {String}
 99   */
100   baseURI: document.baseURI,
101 
102   /** @private
103     A boolean value indicating whether or not the ping method has been called
104     to setup the SC.routes.
105 
106     @property
107     @type {Boolean}
108   */
109   _didSetup: NO,
110 
111   /** @private
112     Internal representation of the current location hash.
113 
114     @property
115     @type {String}
116   */
117   _location: null,
118 
119   /** @private
120     Routes are stored in a tree structure, this is the root node.
121 
122     @property
123     @type {SC.routes._Route}
124   */
125   _firstRoute: null,
126 
127   /** @private
128     Internal method used to extract and merge the parameters of a URL.
129 
130     @returns {Hash}
131   */
132   _extractParametersAndRoute: function(obj, coerce) {
133     var params = {},
134         route = obj.route || '',
135         separator, parts, i, len, crumbs, key;
136 
137     separator = (route.indexOf('?') < 0 && route.indexOf('&') >= 0) ? '&' : '?';
138 
139     parts = route.split(separator);
140     route = parts.shift();
141 
142     params = this.deparam(parts.join('&'), coerce || false);
143 
144     // overlay any parameter passed in obj
145     for (key in obj) {
146       if (obj.hasOwnProperty(key) && key !== 'route') {
147         params[key] = '' + obj[key];
148       }
149     }
150 
151     params.params = separator + $.param(params);
152     params.route = route;
153 
154     return params;
155   },
156 
157   /**
158     The current location hash. It is the part in the browser's location after
159     the '#' mark.
160 
161     The following code
162 
163         SC.routes.set('location', 'notes/edit/4');
164 
165     will change the location to http://domain.tld/my_app#notes/edit/4 and call
166     the correct route handler if it has been registered with the add method.
167 
168     You can also pass additional parameters. They will be relayed to the route
169     handler. For example, the following code
170 
171         SC.routes.add(':controller/:action/:id', MyApp, MyApp.route);
172         SC.routes.set('location', 'notes/show/4?format=xml&language=fr');
173 
174     will change the location to
175     http://domain.tld/my_app#notes/show/4?format=xml&language=fr and call the
176     MyApp.route method with the following argument:
177 
178         { route: 'notes/show/4',
179           params: '?format=xml&language=fr',
180           controller: 'notes',
181           action: 'show',
182           id: '4',
183           format: 'xml',
184           language: 'fr' }
185 
186     The location can also be set with a hash, the following code
187 
188         SC.routes.set('location',
189           { route: 'notes/edit/4', format: 'xml', language: 'fr' });
190 
191     will change the location to
192     http://domain.tld/my_app#notes/show/4?format=xml&language=fr.
193 
194     The 'notes/show/4&format=xml&language=fr' syntax for passing parameters,
195     using a '&' instead of a '?', as used in SproutCore 1.0 is still supported.
196 
197     @property
198     @type {String}
199   */
200   location: function(key, value) {
201     var lsk;
202     this._skipRoute = NO;
203     if (value !== undefined) {
204       // The 'location' and 'informLocation' properties essentially
205       // represent a single property, but with different behavior
206       // when setting the value. Because of this, we manually
207       // update the cached value for the opposite property to
208       // ensure they remain in sync. You shouldn't do this in
209       // your own code unless you REALLY know what you are doing.
210       lsk = this.informLocation.lastSetValueKey;
211       if (lsk && this._kvo_cache) this._kvo_cache[lsk] = value;
212     }
213     return this._extractLocation(key, value);
214   }.property(),
215 
216   /*
217     Works exactly like 'location' but you use this property only when
218     you want to just change the location w/out triggering the routes
219   */
220   informLocation: function(key, value){
221     var lsk;
222     this._skipRoute = YES;
223     if (value !== undefined) {
224       // The 'location' and 'informLocation' properties essentially
225       // represent a single property, but with different behavior
226       // when setting the value. Because of this, we manually
227       // update the cached value for the opposite property to
228       // ensure they remain in sync. You shouldn't do this in
229       // your own code unless you REALLY know what you are doing.
230       lsk = this.location.lastSetValueKey;
231       if (lsk && this._kvo_cache) this._kvo_cache[lsk] = value;
232     }
233     return this._extractLocation(key, value);
234   }.property(),
235 
236   _extractLocation: function(key, value) {
237     var crumbs, encodedValue;
238 
239     if (value !== undefined) {
240       if (value === null) {
241         value = '';
242       }
243 
244       if (typeof(value) === 'object') {
245         crumbs = this._extractParametersAndRoute(value);
246         value = crumbs.route + crumbs.params;
247       }
248 
249       // Only update the browser if this event triggered from within the app, rather
250       // than from the browser back or forward buttons.
251       if (!this._exogenous) {
252         if (!SC.empty(value) || (this._location && this._location !== value)) {
253           encodedValue = encodeURI(value);
254 
255           if (this.usesHistory) {
256             if (encodedValue.length > 0) {
257               encodedValue = '/' + encodedValue;
258             }
259             window.history.pushState(null, null, this.get('baseURI') + encodedValue);
260           } else {
261             window.location.hash = encodedValue;
262           }
263         }
264       }
265 
266       // Cache locally.
267       this._location = value;
268     }
269 
270     return this._location;
271   },
272 
273   /**
274     You usually don't need to call this method. It is done automatically after
275     the application has been initialized.
276 
277     It registers for the hashchange event if available. If not, it creates a
278     timer that looks for location changes every 150ms.
279   */
280   ping: function() {
281     var that;
282 
283     if (!this._didSetup) {
284       this._didSetup = YES;
285 
286       if (this.get('wantsHistory') && SC.platform.supportsHistory) {
287         this.usesHistory = YES;
288 
289         this.popState();
290         SC.Event.add(window, 'popstate', this, this.popState);
291 
292       } else {
293         this.usesHistory = NO;
294 
295         if (SC.platform.supportsHashChange) {
296           this.hashChange();
297           SC.Event.add(window, 'hashchange', this, this.hashChange);
298 
299         } else {
300           // we don't use a SC.Timer because we don't want
301           // a run loop to be triggered at each ping
302           that = this;
303           this._invokeHashChange = function() {
304             that.hashChange();
305             setTimeout(that._invokeHashChange, 100);
306           };
307           this._invokeHashChange();
308         }
309       }
310     }
311   },
312 
313   /**
314     Event handler for the hashchange event. Called automatically by the browser
315     if it supports the hashchange event, or by our timer if not.
316   */
317   hashChange: function(event) {
318     // Mark this location change as coming from the browser, which therefore doesn't
319     // need to be updated.
320     this._exogenous = YES;
321 
322     var loc = window.location.hash;
323 
324     // Remove the '#' prefix
325     loc = (loc && loc.length > 0) ? loc.slice(1, loc.length) : '';
326 
327     if (!SC.browser.isMozilla) {
328       // because of bug https://bugzilla.mozilla.org/show_bug.cgi?id=483304
329       loc = decodeURI(loc);
330     }
331 
332     if (this.get('location') !== loc && !this._skipRoute) {
333       SC.run(function() {
334         this.set('location', loc);
335       }, this);
336     }
337 
338     this._skipRoute = NO;
339     this._exogenous = NO;
340   },
341 
342   popState: function(event) {
343     // Mark this location change as coming from the browser, which therefore doesn't
344     // need to be updated.
345     this._exogenous = YES;
346 
347     var base = this.get('baseURI'),
348         loc = document.location.href;
349 
350     if (loc.slice(0, base.length) === base) {
351 
352       // Remove the base prefix and the extra '/'
353       loc = loc.slice(base.length + 1, loc.length);
354 
355       if (this.get('location') !== loc && !this._skipRoute) {
356         SC.run(function() {
357           this.set('location', loc);
358         }, this);
359       }
360     }
361 
362     this._skipRoute = NO;
363     this._exogenous = NO;
364   },
365 
366   /**
367     Adds a route handler. Routes have the following format:
368 
369      - 'users/show/5' is a static route and only matches this exact string,
370      - ':action/:controller/:id' is a dynamic route and the handler will be
371         called with the 'action', 'controller' and 'id' parameters passed in a
372         hash,
373      - '*url' is a wildcard route, it matches the whole route and the handler
374         will be called with the 'url' parameter passed in a hash.
375 
376     Route types can be combined, the following are valid routes:
377 
378      - 'users/:action/:id'
379      - ':controller/show/:id'
380      - ':controller/*url'
381 
382     @param {String} route the route to be registered
383     @param {Object} target the object on which the method will be called, or
384       directly the function to be called to handle the route
385     @param {Function} method the method to be called on target to handle the
386       route, can be a function or a string
387   */
388   add: function(route, target, method) {
389     if (!this._didSetup) {
390       this.invokeNext(this.ping);
391     }
392 
393     if (method === undefined && SC.typeOf(target) === SC.T_FUNCTION) {
394       method = target;
395       target = null;
396     } else if (SC.typeOf(method) === SC.T_STRING) {
397       method = target[method];
398     }
399 
400     if (!this._firstRoute) this._firstRoute = this._Route.create();
401     this._firstRoute.add(route.split('/'), target, method);
402 
403     return this;
404   },
405 
406   /**
407     Observer of the 'location' property that calls the correct route handler
408     when the location changes.
409   */
410   locationDidChange: function() {
411     this.trigger();
412   }.observes('location'),
413 
414   /**
415     Triggers a route even if already in that route (does change the location, if it
416     is not already changed, as well).
417 
418     If the location is not the same as the supplied location, this simply lets "location"
419     handle it (which ends up coming back to here).
420   */
421   trigger: function() {
422     var firstRoute = this._firstRoute,
423         location = this.get('location'),
424         params, route;
425 
426     if (firstRoute) {
427       params = this._extractParametersAndRoute({ route: location });
428       location = params.route;
429       delete params.route;
430       delete params.params;
431       route = firstRoute.routeForParts(location.split('/'), params);
432       if (route && route.method) {
433         route.method.call(route.target || this, params);
434       }
435     }
436   },
437 
438   /**
439     Function to create an object out of a urlencoded param string.
440 
441     @param {String} string the parameter string
442     @param {Boolean} coerce coerce the values? (Default NO)
443   */
444   deparam: function(string, coerce) {
445     var obj = {},
446       coerce_types = { 'true': !0, 'false': !1, 'null': null },
447       params, len, idx, param, key, val, cur, i, keys, keys_last,
448       dec = decodeURIComponent, toString = Object.prototype.toString;
449 
450     // This allows any URL-like string to also be objectified
451     if (string.indexOf('?') >= 0) {
452       string = string.split('?')[1];
453       if (string.indexOf('#') >= 0) {
454         string = string.split('#')[0];
455       }
456     } else if (string.indexOf('#') >= 0) {
457       string = string.split('#')[1];
458     }
459 
460     params = string.replace(/\+/g, ' ').split('&');
461     len = params.length;
462     for (idx = 0; idx < len; ++idx) {
463       param = params[idx].split('='), key = dec(param[0]), cur = obj,
464         keys = key.split(']['), keys_last = key.length - 1;
465 
466       if (/\[/.test(keys[0]) && /\]$/.test(keys[keys_last])) {
467         keys[keys_last] = keys[keys_last].replace(/\]$/, '');
468         keys = keys.shift().split('[').concat(keys);
469         keys_last = keys.length - 1;
470       } else { keys_last = 0; }
471 
472       if (param.length === 2) {
473         val = dec(param[1]);
474 
475         if (coerce) {
476           val = val && !isNaN(val)              ? +val              // gotta be a number
477             : val === 'undefined'             ? undefined
478             : coerce_types[val] !== undefined ? coerce_types[val]
479             : val;
480         }
481 
482         if (keys_last) {
483           for (i = 0; i < keys_last; ++i) {
484             key = keys[i] === '' ? cur.length : keys[i];
485             cur = cur[key] = i < keys_last
486               ? cur[key] || (keys[i + 1] && isNaN(keys[i + 1]) ? {} : [])
487               : val;
488           }
489         } else {
490           if (toString.apply(obj[key]) === '[object Array]') obj[key].push(val);
491           else if (obj[key] !== undefined) obj[key] = [obj[key], val];
492           else obj[key] = val;
493         }
494       } else if (key) {
495         obj[key] = coerce ? undefined : '';
496       }
497     }
498     return obj;
499   },
500 
501   /**
502     @private
503     @class
504 
505     SC.routes._Route is a class used internally by SC.routes. The routes defined by your
506     application are stored in a tree structure, and this is the class for the
507     nodes.
508   */
509   _Route: SC.Object.extend(
510   /** @scope SC.routes._Route.prototype */ {
511 
512     target: null,
513 
514     method: null,
515 
516     staticRoutes: null,
517 
518     dynamicRoutes: null,
519 
520     wildcardRoutes: null,
521 
522     add: function(parts, target, method) {
523       var part, nextRoute;
524 
525       // clone the parts array because we are going to alter it
526       parts = SC.clone(parts);
527 
528       if (!parts || parts.length === 0) {
529         this.target = target;
530         this.method = method;
531 
532       } else {
533         part = parts.shift();
534 
535         // there are 3 types of routes
536         switch (part.slice(0, 1)) {
537 
538         // 1. dynamic routes
539         case ':':
540           part = part.slice(1, part.length);
541           if (!this.dynamicRoutes) this.dynamicRoutes = {};
542           if (!this.dynamicRoutes[part]) this.dynamicRoutes[part] = this.constructor.create();
543           nextRoute = this.dynamicRoutes[part];
544           break;
545 
546         // 2. wildcard routes
547         case '*':
548           part = part.slice(1, part.length);
549           if (!this.wildcardRoutes) this.wildcardRoutes = {};
550           nextRoute = this.wildcardRoutes[part] = this.constructor.create();
551           break;
552 
553         // 3. static routes
554         default:
555           if (!this.staticRoutes) this.staticRoutes = {};
556           if (!this.staticRoutes[part]) this.staticRoutes[part] = this.constructor.create();
557           nextRoute = this.staticRoutes[part];
558         }
559 
560         // recursively add the rest of the route
561         if (nextRoute) nextRoute.add(parts, target, method);
562       }
563     },
564 
565     routeForParts: function(parts, params) {
566       var part, key, route;
567 
568       // clone the parts array because we are going to alter it
569       parts = SC.clone(parts);
570 
571       // if parts is empty, we are done
572       if (!parts || parts.length === 0) {
573         return this.method ? this : null;
574 
575       } else {
576         part = parts.shift();
577 
578         // try to match a static route
579         if (this.staticRoutes && this.staticRoutes[part]) {
580           return this.staticRoutes[part].routeForParts(parts, params);
581 
582         } else {
583 
584           // else, try to match a dynamic route
585           for (key in this.dynamicRoutes) {
586             route = this.dynamicRoutes[key].routeForParts(parts, params);
587             if (route) {
588               params[key] = part;
589               return route;
590             }
591           }
592 
593           // else, try to match a wildcard route
594           for (key in this.wildcardRoutes) {
595             parts.unshift(part);
596             params[key] = parts.join('/');
597             return this.wildcardRoutes[key].routeForParts(null, params);
598           }
599 
600           // if nothing was found, it means that there is no match
601           return null;
602         }
603       }
604     }
605 
606   })
607 
608 });
609