├── .gitignore ├── lib ├── main.js └── core.js ├── package.json ├── README.md └── tests └── routing.js /.gitignore: -------------------------------------------------------------------------------- 1 | .bpm/ 2 | .spade/ 3 | assets/ 4 | *.bpkg 5 | -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | require('ember-runtime'); 2 | require('sproutcore-routing/core'); 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sproutcore-routing", 3 | "summary": "SproutCore Routing", 4 | "description": "HTML5 History based routing (with a hashchange fallback)", 5 | "homepage": "http://sproutcore.com/", 6 | "author": "Strobe Inc., Apple Inc. and contributors", 7 | "version": "2.0.beta.8", 8 | "directories": { 9 | "lib": "lib" 10 | }, 11 | "dependencies": { 12 | "spade": "~> 1.0", 13 | "jquery": ">= 0", 14 | "ember-runtime": ">= 0" 15 | }, 16 | "dependencies:development": { 17 | "spade-qunit": "~> 1.0.0" 18 | }, 19 | "bpm:build": { 20 | "bpm_libs.js": { 21 | "files": ["lib"], 22 | "modes": "*" 23 | }, 24 | "sproutcore-routing/bpm_tests.js": { 25 | "files": ["tests"], 26 | "modes": ["debug"] 27 | } 28 | }, 29 | "bpm": "1.0.0" 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SproutCore Routing 2 | ------------------ 3 | 4 | SC.routes manages the browser location. You can change the hash part of the 5 | current location. The following code 6 | 7 | ```javascript 8 | SC.routes.set('location', 'notes/edit/4'); 9 | ``` 10 | 11 | will change the location to http://domain.tld/my_app#notes/edit/4. Adding 12 | routes will register a handler that will be called whenever the location 13 | changes and matches the route: 14 | 15 | ```javascript 16 | SC.routes.add(':controller/:action/:id', MyApp, MyApp.route); 17 | ``` 18 | 19 | You can pass additional parameters in the location hash that will be relayed 20 | to the route handler: 21 | 22 | ```javascript 23 | SC.routes.set('location', 'notes/show/4?format=xml&language=fr'); 24 | ``` 25 | 26 | The syntax for the location hash is described in the location property 27 | documentation, and the syntax for adding handlers is described in the 28 | add method documentation. 29 | 30 | Browsers keep track of the locations in their history, so when the user 31 | presses the 'back' or 'forward' button, the location is changed, SC.route 32 | catches it and calls your handler. Except for Internet Explorer versions 7 33 | and earlier, which do not modify the history stack when the location hash 34 | changes. 35 | 36 | SC.routes also supports HTML5 history, which uses a '/' instead of a '#' 37 | in the URLs, so that all your website's URLs are consistent. 38 | -------------------------------------------------------------------------------- /tests/routing.js: -------------------------------------------------------------------------------- 1 | // ========================================================================== 2 | // Project: SproutCore - JavaScript Application Framework 3 | // Copyright: ©2006-2011 Strobe Inc. and contributors. 4 | // ©2008-2011 Apple Inc. All rights reserved. 5 | // License: Licensed under MIT license (see license.js) 6 | // ========================================================================== 7 | // ======================================================================== 8 | // SC.routes Base Tests 9 | // ======================================================================== 10 | /*globals module test ok isObj equals expects */ 11 | 12 | var router; 13 | 14 | SC.routes.wantsHistory = YES; 15 | 16 | module('SC.routes setup'); 17 | 18 | test('Setup', function() { 19 | equals(SC.routes._didSetup, NO, 'SC.routes should not have been setup yet'); 20 | }); 21 | 22 | module('SC.routes setup', { 23 | 24 | setup: function() { 25 | router = SC.Object.create({ 26 | route: function() { 27 | return; 28 | } 29 | }); 30 | SC.run(function() { 31 | SC.routes.add('foo', router, router.route); 32 | }); 33 | } 34 | 35 | }); 36 | 37 | test('Setup', function() { 38 | equals(SC.routes._didSetup, YES, 'SC.routes should have been setup'); 39 | }); 40 | 41 | test('Initial route', function() { 42 | equals(SC.routes.get('location'), '', 'Initial route is an empty string'); 43 | }); 44 | 45 | module('SC.routes._Route', { 46 | 47 | setup: function() { 48 | SC.routes._firstRoute = null; 49 | router = SC.Object.create({ 50 | route: function() { 51 | return; 52 | } 53 | }); 54 | } 55 | 56 | }); 57 | 58 | test('Route tree', function() { 59 | var r = SC.routes._Route.create(), 60 | abc = ['a', 'b', 'c'], 61 | abd = ['a', 'b', 'd'], 62 | abe = ['a', 'b', ':e'], 63 | fs = ['f', '*foo'], 64 | a, b, c, d, e, s, p; 65 | 66 | r.add(abc, router, router.route); 67 | r.add(abd, router, router.route); 68 | r.add(abe, router, router.route); 69 | r.add(fs, router, router.route); 70 | 71 | a = r.staticRoutes.a; 72 | ok(a, 'There should be a staticRoutes tree for a'); 73 | ok(!a.target, 'A node should not have a target'); 74 | ok(!a.method, 'A node should not have a method'); 75 | 76 | b = a.staticRoutes.b; 77 | ok(b, 'There should be a staticRoutes tree for b'); 78 | ok(!b.target, 'A node should not have a target'); 79 | ok(!b.method, 'A node should not have a method'); 80 | 81 | c = b.staticRoutes.c; 82 | ok(c, 'There should be a staticRoutes tree for c'); 83 | equals(c.target, router, 'A leaf should have a target'); 84 | equals(c.method, router.route, 'A leaf should have a method'); 85 | 86 | d = b.staticRoutes.d; 87 | ok(d, 'There should be a staticRoutes tree for d'); 88 | equals(d.target, router, 'A leaf should have a target'); 89 | equals(d.method, router.route, 'A leaf should have a method'); 90 | 91 | e = b.dynamicRoutes.e; 92 | ok(e, 'There should be a dynamicRoutes tree for e'); 93 | equals(d.target, router, 'A leaf should have a target'); 94 | equals(d.method, router.route, 'A leaf should have a method'); 95 | 96 | s = r.staticRoutes.f.wildcardRoutes.foo; 97 | ok(s, 'There should be a wildcardRoutes tree for f'); 98 | 99 | equals(r.routeForParts(['a'], {}), null, 'routeForParts should return null for non existant routes'); 100 | equals(r.routeForParts(['a', 'b'], {}), null, 'routeForParts should return null for non existant routes'); 101 | equals(r.routeForParts(abc, {}), c, 'routeForParts should return the correct route for a/b/c'); 102 | 103 | equals(r.routeForParts(abd, {}), d, 'routeForParts should return the correct route for a/b/d'); 104 | 105 | abe[2] = 'foo'; 106 | p = {}; 107 | equals(r.routeForParts(abe, p), e, 'routeForParts should return the correct route for a/b/:e'); 108 | equals(p.e, 'foo', 'routeForParts should return the params for a/b/:e'); 109 | 110 | p = {}; 111 | equals(r.routeForParts(['f', 'double', 'double', 'toil', 'and', 'trouble'], p), s, 'routeForParts should return the correct route for f/*foo'); 112 | equals(p.foo, 'double/double/toil/and/trouble', 'routeForParts should return the params for f/*foo'); 113 | }); 114 | 115 | module('SC.routes location', { 116 | 117 | setup: function() { 118 | SC.routes._firstRoute = null 119 | }, 120 | 121 | teardown: function() { 122 | SC.routes.set('location', null); 123 | } 124 | 125 | }); 126 | 127 | var routeWorks = function(route, name) { 128 | stop(); 129 | 130 | SC.routes.set('location', route); 131 | equals(SC.routes.get('location'), route, name + ' route has been set'); 132 | 133 | setTimeout(function() { 134 | equals(SC.routes.get('location'), route, name + ' route is still the same'); 135 | start(); 136 | }, 300); 137 | }; 138 | 139 | test('Null route', function() { 140 | SC.routes.set('location', null); 141 | equals(SC.routes.get('location'), '', 'Null route is the empty string'); 142 | }); 143 | 144 | test('Simple route', function() { 145 | routeWorks('sixty-six', 'simple'); 146 | }); 147 | 148 | test('UTF-8 route', function() { 149 | routeWorks('éàçù߀', 'UTF-8'); 150 | }); 151 | 152 | test('Already escaped route', function() { 153 | routeWorks('%C3%A9%C3%A0%20%C3%A7%C3%B9%20%C3%9F%E2%82%AC', 'already escaped'); 154 | }); 155 | 156 | module('SC.routes defined routes', { 157 | 158 | setup: function() { 159 | SC.routes._firstRoute = null; 160 | router = SC.Object.create({ 161 | params: null, 162 | triggered: NO, 163 | route: function(params) { 164 | this.set('params', params); 165 | }, 166 | triggerRoute: function() { 167 | this.triggered = YES; 168 | } 169 | }); 170 | }, 171 | 172 | teardown: function() { 173 | SC.routes.set('location', null); 174 | } 175 | 176 | }); 177 | 178 | test('setting location triggers function when only passed function', function() { 179 | var barred = false; 180 | 181 | SC.routes.add('bar', function(params) { 182 | barred = true; 183 | }); 184 | SC.routes.set('location', 'bar'); 185 | 186 | ok(barred, 'Function was called'); 187 | }); 188 | 189 | test('setting location simply triggers route', function() { 190 | SC.routes.add("foo", router, "triggerRoute"); 191 | SC.routes.set('location', 'bar'); 192 | ok(!router.triggered, "Router not triggered with nonexistent route."); 193 | 194 | SC.routes.set('location', 'foo'); 195 | ok(router.triggered, "Router triggered."); 196 | }); 197 | 198 | test('calling trigger() triggers current location (again)', function() { 199 | SC.routes.add("foo", router, "triggerRoute"); 200 | SC.routes.set('location', 'foo'); 201 | ok(router.triggered, "Router triggered first time."); 202 | router.triggered = NO; 203 | 204 | SC.routes.trigger(); 205 | ok(router.triggered, "Router triggered (again)."); 206 | }); 207 | 208 | test('A mix of static, dynamic and wildcard route', function() { 209 | stop(); 210 | 211 | var timer = setTimeout(function() { 212 | ok(false, 'Route change was not notified within 2 seconds'); 213 | start(); 214 | }, 2000); 215 | 216 | router.addObserver('params', function() { 217 | router.removeObserver('params', this); 218 | same(router.get('params'), { controller: 'users', action: 'éàçù߀', id: '5', witches: 'double/double/toil/and/trouble' }); 219 | clearTimeout(timer); 220 | start(); 221 | }); 222 | 223 | SC.routes.add('foo/:controller/:action/bar/:id/*witches', router, router.route); 224 | SC.routes.set('location', 'foo/users/éàçù߀/bar/5/double/double/toil/and/trouble'); 225 | }); 226 | 227 | test('Route with parameters defined in a string', function() { 228 | stop(); 229 | 230 | var timer = setTimeout(function() { 231 | ok(false, 'Route change was not notified within 2 seconds'); 232 | start(); 233 | }, 2000); 234 | 235 | router.addObserver('params', function() { 236 | router.removeObserver('params', this); 237 | same(router.get('params'), { cuisine: 'french', party: '4', url: '' }); 238 | clearTimeout(timer); 239 | start(); 240 | }); 241 | 242 | SC.routes.add('*url', router, router.route); 243 | SC.routes.set('location', '?cuisine=french&party=4'); 244 | }); 245 | 246 | test('Route with parameters defined in a hash', function() { 247 | stop(); 248 | 249 | var timer = setTimeout(function() { 250 | ok(false, 'Route change was not notified within 2 seconds'); 251 | start(); 252 | }, 2000); 253 | 254 | router.addObserver('params', function() { 255 | router.removeObserver('params', this); 256 | same(router.get('params'), { cuisine: 'french', party: '4', url: '' }); 257 | clearTimeout(timer); 258 | start(); 259 | }); 260 | 261 | SC.routes.add('*url', router, router.route); 262 | SC.routes.set('location', { cuisine: 'french', party: '4' }); 263 | }); 264 | 265 | test('A mix of everything', function() { 266 | stop(); 267 | 268 | var timer = setTimeout(function() { 269 | ok(false, 'Route change was not notified within 2 seconds'); 270 | start(); 271 | }, 2000); 272 | 273 | router.addObserver('params', function() { 274 | router.removeObserver('params', this); 275 | same(router.get('params'), { controller: 'users', action: 'éàçù߀', id: '5', witches: 'double/double/toil/and/trouble', cuisine: 'french', party: '4' }); 276 | clearTimeout(timer); 277 | start(); 278 | }); 279 | 280 | SC.routes.add('foo/:controller/:action/bar/:id/*witches', router, router.route); 281 | SC.routes.set('location', 'foo/users/éàçù߀/bar/5/double/double/toil/and/trouble?cuisine=french&party=4'); 282 | }); 283 | 284 | test('calling exists() returns whether the route is defined or not', function() { 285 | equal(SC.routes.exists('foo'), false, 'Route /foo should not exist'); 286 | 287 | SC.routes.add("foo", router, "triggerRoute"); 288 | 289 | equal(SC.routes.exists('foo'), true, 'Route /foo should exist'); 290 | equal(SC.routes.exists('quux'), false, 'Route /quux should not exist'); 291 | }); 292 | 293 | module('SC.routes location observing', { 294 | 295 | setup: function() { 296 | SC.routes._firstRoute = null; 297 | router = SC.Object.create({ 298 | hasBeenNotified: NO, 299 | route: function(params) { 300 | this.set('hasBeenNotified', YES); 301 | } 302 | }); 303 | }, 304 | 305 | teardown: function() { 306 | SC.routes.set('location', null); 307 | } 308 | 309 | }); 310 | 311 | test('Location change', function() { 312 | if (!SC.routes.get('usesHistory')) { 313 | stop(); 314 | 315 | var timer = setTimeout(function() { 316 | ok(false, 'Route change was not notified within 2 seconds'); 317 | start(); 318 | }, 2000); 319 | 320 | router.addObserver('hasBeenNotified', function() { 321 | clearTimeout(timer); 322 | equals(router.get('hasBeenNotified'), YES, 'router should have been notified'); 323 | start(); 324 | }); 325 | 326 | SC.routes.add('foo', router, router.route); 327 | window.location.hash = 'foo'; 328 | } 329 | }); 330 | 331 | module('_extractParametersAndRoute'); 332 | 333 | test('_extractParametersAndRoute with ? syntax', function() { 334 | same(SC.routes._extractParametersAndRoute({ route: 'videos/5?format=h264' }), 335 | { route: 'videos/5', params:'?format=h264', format: 'h264' }, 336 | 'route parameters should be correctly extracted'); 337 | 338 | same(SC.routes._extractParametersAndRoute({ route: 'videos/5?format=h264&size=small' }), 339 | { route: 'videos/5', params:'?format=h264&size=small', format: 'h264', size: 'small' }, 340 | 'route parameters should be correctly extracted'); 341 | 342 | same(SC.routes._extractParametersAndRoute({ route: 'videos/5?format=h264&size=small', format: 'ogg' }), 343 | { route: 'videos/5', params:'?format=ogg&size=small', format: 'ogg', size: 'small' }, 344 | 'route parameters should be extracted and overwritten'); 345 | 346 | same(SC.routes._extractParametersAndRoute({ route: 'videos/5', format: 'h264', size: 'small' }), 347 | { route: 'videos/5', params:'?format=h264&size=small', format: 'h264', size: 'small' }, 348 | 'route should be well formatted with the given parameters'); 349 | 350 | same(SC.routes._extractParametersAndRoute({ format: 'h264', size: 'small' }), 351 | { route: '', params:'?format=h264&size=small', format: 'h264', size: 'small' }, 352 | 'route should be well formatted with the given parameters even if there is no initial route'); 353 | }); 354 | 355 | test('_extractParametersAndRoute with & syntax', function() { 356 | same(SC.routes._extractParametersAndRoute({ route: 'videos/5&format=h264' }), 357 | { route: 'videos/5', params:'&format=h264', format: 'h264' }, 358 | 'route parameters should be correctly extracted'); 359 | 360 | same(SC.routes._extractParametersAndRoute({ route: 'videos/5&format=h264&size=small' }), 361 | { route: 'videos/5', params:'&format=h264&size=small', format: 'h264', size: 'small' }, 362 | 'route parameters should be correctly extracted'); 363 | 364 | same(SC.routes._extractParametersAndRoute({ route: 'videos/5&format=h264&size=small', format: 'ogg' }), 365 | { route: 'videos/5', params:'&format=ogg&size=small', format: 'ogg', size: 'small' }, 366 | 'route parameters should be extracted and overwritten'); 367 | }); 368 | -------------------------------------------------------------------------------- /lib/core.js: -------------------------------------------------------------------------------- 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 | // Ember no longer aliases SC 9 | window.SC = window.SC || Ember.Namespace.create(); 10 | 11 | var get = Ember.get, set = Ember.set; 12 | 13 | /** 14 | Wether the browser supports HTML5 history. 15 | */ 16 | var supportsHistory = !!(window.history && window.history.pushState); 17 | 18 | /** 19 | Wether the browser supports the hashchange event. 20 | */ 21 | var supportsHashChange = ('onhashchange' in window) && (document.documentMode === undefined || document.documentMode > 7); 22 | 23 | /** 24 | @class 25 | 26 | Route is a class used internally by SC.routes. The routes defined by your 27 | application are stored in a tree structure, and this is the class for the 28 | nodes. 29 | */ 30 | var Route = Ember.Object.extend( 31 | /** @scope Route.prototype */ { 32 | 33 | target: null, 34 | 35 | method: null, 36 | 37 | staticRoutes: null, 38 | 39 | dynamicRoutes: null, 40 | 41 | wildcardRoutes: null, 42 | 43 | add: function(parts, target, method) { 44 | var part, nextRoute; 45 | 46 | // clone the parts array because we are going to alter it 47 | parts = Ember.copy(parts); 48 | 49 | if (!parts || parts.length === 0) { 50 | this.target = target; 51 | this.method = method; 52 | 53 | } else { 54 | part = parts.shift(); 55 | 56 | // there are 3 types of routes 57 | switch (part.slice(0, 1)) { 58 | 59 | // 1. dynamic routes 60 | case ':': 61 | part = part.slice(1, part.length); 62 | if (!this.dynamicRoutes) this.dynamicRoutes = {}; 63 | if (!this.dynamicRoutes[part]) this.dynamicRoutes[part] = this.constructor.create(); 64 | nextRoute = this.dynamicRoutes[part]; 65 | break; 66 | 67 | // 2. wildcard routes 68 | case '*': 69 | part = part.slice(1, part.length); 70 | if (!this.wildcardRoutes) this.wildcardRoutes = {}; 71 | nextRoute = this.wildcardRoutes[part] = this.constructor.create(); 72 | break; 73 | 74 | // 3. static routes 75 | default: 76 | if (!this.staticRoutes) this.staticRoutes = {}; 77 | if (!this.staticRoutes[part]) this.staticRoutes[part] = this.constructor.create(); 78 | nextRoute = this.staticRoutes[part]; 79 | } 80 | 81 | // recursively add the rest of the route 82 | if (nextRoute) nextRoute.add(parts, target, method); 83 | } 84 | }, 85 | 86 | routeForParts: function(parts, params) { 87 | var part, key, route; 88 | 89 | // clone the parts array because we are going to alter it 90 | parts = Ember.copy(parts); 91 | 92 | // if parts is empty, we are done 93 | if (!parts || parts.length === 0) { 94 | return this.method ? this : null; 95 | 96 | } else { 97 | part = parts.shift(); 98 | 99 | // try to match a static route 100 | if (this.staticRoutes && this.staticRoutes[part]) { 101 | route = this.staticRoutes[part].routeForParts(parts, params); 102 | if (route) { 103 | return route; 104 | } 105 | } 106 | 107 | // else, try to match a dynamic route 108 | for (key in this.dynamicRoutes) { 109 | route = this.dynamicRoutes[key].routeForParts(parts, params); 110 | if (route) { 111 | params[key] = part; 112 | return route; 113 | } 114 | } 115 | 116 | // else, try to match a wilcard route 117 | for (key in this.wildcardRoutes) { 118 | parts.unshift(part); 119 | params[key] = parts.join('/'); 120 | return this.wildcardRoutes[key].routeForParts(null, params); 121 | } 122 | 123 | // if nothing was found, it means that there is no match 124 | return null; 125 | } 126 | } 127 | 128 | }); 129 | 130 | /** 131 | @class 132 | 133 | SC.routes manages the browser location. You can change the hash part of the 134 | current location. The following code 135 | 136 | SC.routes.set('location', 'notes/edit/4'); 137 | 138 | will change the location to http://domain.tld/my_app#notes/edit/4. Adding 139 | routes will register a handler that will be called whenever the location 140 | changes and matches the route: 141 | 142 | SC.routes.add(':controller/:action/:id', MyApp, MyApp.route); 143 | 144 | You can pass additional parameters in the location hash that will be relayed 145 | to the route handler: 146 | 147 | SC.routes.set('location', 'notes/show/4?format=xml&language=fr'); 148 | 149 | The syntax for the location hash is described in the location property 150 | documentation, and the syntax for adding handlers is described in the 151 | add method documentation. 152 | 153 | Browsers keep track of the locations in their history, so when the user 154 | presses the 'back' or 'forward' button, the location is changed, SC.route 155 | catches it and calls your handler. Except for Internet Explorer versions 7 156 | and earlier, which do not modify the history stack when the location hash 157 | changes. 158 | 159 | SC.routes also supports HTML5 history, which uses a '/' instead of a '#' 160 | in the URLs, so that all your website's URLs are consistent. 161 | */ 162 | var routes = SC.routes = Ember.Object.create( 163 | /** @scope SC.routes.prototype */{ 164 | 165 | /** 166 | Set this property to true if you want to use HTML5 history, if available on 167 | the browser, instead of the location hash. 168 | 169 | HTML 5 history uses the history.pushState method and the window's popstate 170 | event. 171 | 172 | By default it is false, so your URLs will look like: 173 | 174 | http://domain.tld/my_app#notes/edit/4 175 | 176 | If set to true and the browser supports pushState(), your URLs will look 177 | like: 178 | 179 | http://domain.tld/my_app/notes/edit/4 180 | 181 | You will also need to make sure that baseURI is properly configured, as 182 | well as your server so that your routes are properly pointing to your 183 | SproutCore application. 184 | 185 | @see http://dev.w3.org/html5/spec/history.html#the-history-interface 186 | @property 187 | @type {Boolean} 188 | */ 189 | wantsHistory: false, 190 | 191 | /** 192 | A read-only boolean indicating whether or not HTML5 history is used. Based 193 | on the value of wantsHistory and the browser's support for pushState. 194 | 195 | @see wantsHistory 196 | @property 197 | @type {Boolean} 198 | */ 199 | usesHistory: null, 200 | 201 | /** 202 | The base URI used to resolve routes (which are relative URLs). Only used 203 | when usesHistory is equal to true. 204 | 205 | The build tools automatically configure this value if you have the 206 | html5_history option activated in the Buildfile: 207 | 208 | config :my_app, :html5_history => true 209 | 210 | Alternatively, it uses by default the value of the href attribute of the 211 | tag of the HTML document. For example: 212 | 213 | 214 | 215 | The value can also be customized before or during the exectution of the 216 | main() method. 217 | 218 | @see http://www.w3.org/TR/html5/semantics.html#the-base-element 219 | @property 220 | @type {String} 221 | */ 222 | baseURI: document.baseURI, 223 | 224 | /** @private 225 | A boolean value indicating whether or not the ping method has been called 226 | to setup the SC.routes. 227 | 228 | @property 229 | @type {Boolean} 230 | */ 231 | _didSetup: false, 232 | 233 | /** @private 234 | Internal representation of the current location hash. 235 | 236 | @property 237 | @type {String} 238 | */ 239 | _location: null, 240 | 241 | /** @private 242 | Routes are stored in a tree structure, this is the root node. 243 | 244 | @property 245 | @type {Route} 246 | */ 247 | _firstRoute: null, 248 | 249 | /** @private 250 | An internal reference to the Route class. 251 | 252 | @property 253 | */ 254 | _Route: Route, 255 | 256 | /** @private 257 | Internal method used to extract and merge the parameters of a URL. 258 | 259 | @returns {Hash} 260 | */ 261 | _extractParametersAndRoute: function(obj) { 262 | var params = {}, 263 | route = obj.route || '', 264 | separator, parts, i, len, crumbs, key; 265 | 266 | separator = (route.indexOf('?') < 0 && route.indexOf('&') >= 0) ? '&' : '?'; 267 | parts = route.split(separator); 268 | route = parts[0]; 269 | if (parts.length === 1) { 270 | parts = []; 271 | } else if (parts.length === 2) { 272 | parts = parts[1].split('&'); 273 | } else if (parts.length > 2) { 274 | parts.shift(); 275 | } 276 | 277 | // extract the parameters from the route string 278 | len = parts.length; 279 | for (i = 0; i < len; ++i) { 280 | crumbs = parts[i].split('='); 281 | params[crumbs[0]] = crumbs[1]; 282 | } 283 | 284 | // overlay any parameter passed in obj 285 | for (key in obj) { 286 | if (obj.hasOwnProperty(key) && key !== 'route') { 287 | params[key] = '' + obj[key]; 288 | } 289 | } 290 | 291 | // build the route 292 | parts = []; 293 | for (key in params) { 294 | parts.push([key, params[key]].join('=')); 295 | } 296 | params.params = separator + parts.join('&'); 297 | params.route = route; 298 | 299 | return params; 300 | }, 301 | 302 | /** 303 | The current location hash. It is the part in the browser's location after 304 | the '#' mark. 305 | 306 | The following code 307 | 308 | SC.routes.set('location', 'notes/edit/4'); 309 | 310 | will change the location to http://domain.tld/my_app#notes/edit/4 and call 311 | the correct route handler if it has been registered with the add method. 312 | 313 | You can also pass additional parameters. They will be relayed to the route 314 | handler. For example, the following code 315 | 316 | SC.routes.add(':controller/:action/:id', MyApp, MyApp.route); 317 | SC.routes.set('location', 'notes/show/4?format=xml&language=fr'); 318 | 319 | will change the location to 320 | http://domain.tld/my_app#notes/show/4?format=xml&language=fr and call the 321 | MyApp.route method with the following argument: 322 | 323 | { route: 'notes/show/4', 324 | params: '?format=xml&language=fr', 325 | controller: 'notes', 326 | action: 'show', 327 | id: '4', 328 | format: 'xml', 329 | language: 'fr' } 330 | 331 | The location can also be set with a hash, the following code 332 | 333 | SC.routes.set('location', 334 | { route: 'notes/edit/4', format: 'xml', language: 'fr' }); 335 | 336 | will change the location to 337 | http://domain.tld/my_app#notes/show/4?format=xml&language=fr. 338 | 339 | The 'notes/show/4&format=xml&language=fr' syntax for passing parameters, 340 | using a '&' instead of a '?', as used in SproutCore 1.0 is still supported. 341 | 342 | @property 343 | @type {String} 344 | */ 345 | location: Ember.computed(function(key, value) { 346 | this._skipRoute = false; 347 | return this._extractLocation(key, value); 348 | }).property(), 349 | 350 | _extractLocation: function(key, value) { 351 | var crumbs, encodedValue; 352 | 353 | if (value !== undefined) { 354 | if (value === null) { 355 | value = ''; 356 | } 357 | 358 | if (typeof(value) === 'object') { 359 | crumbs = this._extractParametersAndRoute(value); 360 | value = crumbs.route + crumbs.params; 361 | } 362 | 363 | if (!this._skipPush && (!Ember.empty(value) || (this._location && this._location !== value))) { 364 | encodedValue = encodeURI(value); 365 | 366 | if (this.usesHistory) { 367 | var basePath = get(this, 'baseURI'); 368 | if (basePath.charAt(basePath.length-1) !== "/") { 369 | basePath += "/"; 370 | } 371 | 372 | window.history.pushState(null, null, basePath + encodedValue); 373 | } else if (encodedValue.length > 0 || window.location.hash.length > 0) { 374 | window.location.hash = encodedValue; 375 | } 376 | } 377 | 378 | this._location = value; 379 | } 380 | 381 | return this._location; 382 | }, 383 | 384 | updateLocation: function(loc){ 385 | this._skipRoute = true; 386 | return this._extractLocation('location', loc); 387 | }, 388 | 389 | /** 390 | You usually don't need to call this method. It is done automatically after 391 | the application has been initialized. 392 | 393 | It registers for the hashchange event if available. If not, it creates a 394 | timer that looks for location changes every 150ms. 395 | */ 396 | ping: function() { 397 | if (!this._didSetup) { 398 | this._didSetup = true; 399 | var state; 400 | 401 | if (get(this, 'wantsHistory') && supportsHistory) { 402 | this.usesHistory = true; 403 | 404 | // Move any hash state to url state 405 | // TODO: Make sure we have a hash before adding slash 406 | state = window.location.hash.slice(1); 407 | if (state.length > 0) { 408 | state = '/' + state; 409 | window.history.replaceState(null, null, get(this, 'baseURI')+state); 410 | } 411 | 412 | popState(); 413 | jQuery(window).bind('popstate', popState); 414 | 415 | } else { 416 | this.usesHistory = false; 417 | 418 | if (get(this, 'wantsHistory')) { 419 | // Move any url state to hash 420 | var base = get(this, 'baseURI'); 421 | var loc = (base.charAt(0) === '/') ? document.location.pathname : document.location.href.replace(document.location.hash, ''); 422 | state = loc.slice(base.length+1); 423 | if (state.length > 0) { 424 | window.location.href = base+'#'+state; 425 | } 426 | } 427 | 428 | if (supportsHashChange) { 429 | hashChange(); 430 | jQuery(window).bind('hashchange', hashChange); 431 | 432 | } else { 433 | var invokeHashChange = function() { 434 | hashChange(); 435 | setTimeout(invokeHashChange, 100); 436 | }; 437 | invokeHashChange(); 438 | } 439 | } 440 | } 441 | }, 442 | 443 | /** 444 | Adds a route handler. Routes have the following format: 445 | 446 | - 'users/show/5' is a static route and only matches this exact string, 447 | - ':action/:controller/:id' is a dynamic route and the handler will be 448 | called with the 'action', 'controller' and 'id' parameters passed in a 449 | hash, 450 | - '*url' is a wildcard route, it matches the whole route and the handler 451 | will be called with the 'url' parameter passed in a hash. 452 | 453 | Route types can be combined, the following are valid routes: 454 | 455 | - 'users/:action/:id' 456 | - ':controller/show/:id' 457 | - ':controller/ *url' (ignore the space, because of jslint) 458 | 459 | @param {String} route the route to be registered 460 | @param {Object} target the object on which the method will be called, or 461 | directly the function to be called to handle the route 462 | @param {Function} method the method to be called on target to handle the 463 | route, can be a function or a string 464 | */ 465 | add: function(route, target, method) { 466 | if (!this._didSetup) { 467 | Ember.run.once(this, 'ping'); 468 | } 469 | 470 | if (method === undefined && Ember.typeOf(target) === 'function') { 471 | method = target; 472 | target = null; 473 | } else if (Ember.typeOf(method) === 'string') { 474 | method = target[method]; 475 | } 476 | 477 | if (!this._firstRoute) this._firstRoute = Route.create(); 478 | this._firstRoute.add(route.split('/'), target, method); 479 | 480 | return this; 481 | }, 482 | 483 | /** 484 | Observer of the 'location' property that calls the correct route handler 485 | when the location changes. 486 | */ 487 | locationDidChange: Ember.observer(function() { 488 | this.trigger(); 489 | }, 'location'), 490 | 491 | /** 492 | Triggers a route even if already in that route (does change the location, if it 493 | is not already changed, as well). 494 | 495 | If the location is not the same as the supplied location, this simply lets "location" 496 | handle it (which ends up coming back to here). 497 | */ 498 | trigger: function() { 499 | var location = get(this, 'location'), 500 | params, route; 501 | 502 | if (this._firstRoute) { 503 | params = this._extractParametersAndRoute({ route: location }); 504 | location = params.route; 505 | delete params.route; 506 | delete params.params; 507 | 508 | route = this.getRoute(location, params); 509 | if (route && route.method) { 510 | route.method.call(route.target || this, params); 511 | } 512 | } 513 | }, 514 | 515 | getRoute: function(route, params) { 516 | var firstRoute = this._firstRoute; 517 | if (firstRoute == null) { 518 | return null; 519 | } 520 | 521 | if (params == null) { 522 | params = {}; 523 | } 524 | 525 | return firstRoute.routeForParts(route.split('/'), params); 526 | }, 527 | 528 | exists: function(route, params) { 529 | route = this.getRoute(route, params); 530 | return route !== null && route.method !== null; 531 | } 532 | 533 | }); 534 | 535 | /** 536 | Event handler for the hashchange event. Called automatically by the browser 537 | if it supports the hashchange event, or by our timer if not. 538 | */ 539 | function hashChange(event) { 540 | var loc = window.location.hash; 541 | 542 | // Remove the '#' prefix 543 | loc = (loc && loc.length > 0) ? loc.slice(1, loc.length) : ''; 544 | 545 | if (!jQuery.browser.mozilla) { 546 | // because of bug https://bugzilla.mozilla.org/show_bug.cgi?id=483304 547 | loc = decodeURI(loc); 548 | } 549 | 550 | if (get(routes, 'location') !== loc && !routes._skipRoute) { 551 | Ember.run.once(function() { 552 | routes._skipPush = true; 553 | set(routes, 'location', loc); 554 | routes._skipPush = false; 555 | }); 556 | } 557 | routes._skipRoute = false; 558 | } 559 | 560 | function popState(event) { 561 | var base = get(routes, 'baseURI'), 562 | loc = (base.charAt(0) === '/') ? document.location.pathname : document.location.href; 563 | 564 | if (loc.slice(0, base.length) === base) { 565 | // Remove the base prefix and the extra '/' 566 | loc = loc.slice(base.length + 1, loc.length); 567 | 568 | if (get(routes, 'location') !== loc && !routes._skipRoute) { 569 | Ember.run.once(function() { 570 | routes._skipPush = true; 571 | set(routes, 'location', loc); 572 | routes._skipPush = false; 573 | }); 574 | } 575 | } 576 | routes._skipRoute = false; 577 | } 578 | --------------------------------------------------------------------------------