route-recognizer.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. (function(__exports__) {
  2. "use strict";
  3. var specials = [
  4. '/', '.', '*', '+', '?', '|',
  5. '(', ')', '[', ']', '{', '}', '\\'
  6. ];
  7. var escapeRegex = new RegExp('(\\' + specials.join('|\\') + ')', 'g');
  8. function isArray(test) {
  9. return Object.prototype.toString.call(test) === "[object Array]";
  10. }
  11. // A Segment represents a segment in the original route description.
  12. // Each Segment type provides an `eachChar` and `regex` method.
  13. //
  14. // The `eachChar` method invokes the callback with one or more character
  15. // specifications. A character specification consumes one or more input
  16. // characters.
  17. //
  18. // The `regex` method returns a regex fragment for the segment. If the
  19. // segment is a dynamic of star segment, the regex fragment also includes
  20. // a capture.
  21. //
  22. // A character specification contains:
  23. //
  24. // * `validChars`: a String with a list of all valid characters, or
  25. // * `invalidChars`: a String with a list of all invalid characters
  26. // * `repeat`: true if the character specification can repeat
  27. function StaticSegment(string) { this.string = string; }
  28. StaticSegment.prototype = {
  29. eachChar: function(callback) {
  30. var string = this.string, ch;
  31. for (var i=0, l=string.length; i<l; i++) {
  32. ch = string.charAt(i);
  33. callback({ validChars: ch });
  34. }
  35. },
  36. regex: function() {
  37. return this.string.replace(escapeRegex, '\\$1');
  38. },
  39. generate: function() {
  40. return this.string;
  41. }
  42. };
  43. function DynamicSegment(name) { this.name = name; }
  44. DynamicSegment.prototype = {
  45. eachChar: function(callback) {
  46. callback({ invalidChars: "/", repeat: true });
  47. },
  48. regex: function() {
  49. return "([^/]+)";
  50. },
  51. generate: function(params) {
  52. return params[this.name];
  53. }
  54. };
  55. function StarSegment(name) { this.name = name; }
  56. StarSegment.prototype = {
  57. eachChar: function(callback) {
  58. callback({ invalidChars: "", repeat: true });
  59. },
  60. regex: function() {
  61. return "(.+)";
  62. },
  63. generate: function(params) {
  64. return params[this.name];
  65. }
  66. };
  67. function EpsilonSegment() {}
  68. EpsilonSegment.prototype = {
  69. eachChar: function() {},
  70. regex: function() { return ""; },
  71. generate: function() { return ""; }
  72. };
  73. function parse(route, names, types) {
  74. // normalize route as not starting with a "/". Recognition will
  75. // also normalize.
  76. if (route.charAt(0) === "/") { route = route.substr(1); }
  77. var segments = route.split("/"), results = [];
  78. for (var i=0, l=segments.length; i<l; i++) {
  79. var segment = segments[i], match;
  80. if (match = segment.match(/^:([^\/]+)$/)) {
  81. results.push(new DynamicSegment(match[1]));
  82. names.push(match[1]);
  83. types.dynamics++;
  84. } else if (match = segment.match(/^\*([^\/]+)$/)) {
  85. results.push(new StarSegment(match[1]));
  86. names.push(match[1]);
  87. types.stars++;
  88. } else if(segment === "") {
  89. results.push(new EpsilonSegment());
  90. } else {
  91. results.push(new StaticSegment(segment));
  92. types.statics++;
  93. }
  94. }
  95. return results;
  96. }
  97. // A State has a character specification and (`charSpec`) and a list of possible
  98. // subsequent states (`nextStates`).
  99. //
  100. // If a State is an accepting state, it will also have several additional
  101. // properties:
  102. //
  103. // * `regex`: A regular expression that is used to extract parameters from paths
  104. // that reached this accepting state.
  105. // * `handlers`: Information on how to convert the list of captures into calls
  106. // to registered handlers with the specified parameters
  107. // * `types`: How many static, dynamic or star segments in this route. Used to
  108. // decide which route to use if multiple registered routes match a path.
  109. //
  110. // Currently, State is implemented naively by looping over `nextStates` and
  111. // comparing a character specification against a character. A more efficient
  112. // implementation would use a hash of keys pointing at one or more next states.
  113. function State(charSpec) {
  114. this.charSpec = charSpec;
  115. this.nextStates = [];
  116. }
  117. State.prototype = {
  118. get: function(charSpec) {
  119. var nextStates = this.nextStates;
  120. for (var i=0, l=nextStates.length; i<l; i++) {
  121. var child = nextStates[i];
  122. var isEqual = child.charSpec.validChars === charSpec.validChars;
  123. isEqual = isEqual && child.charSpec.invalidChars === charSpec.invalidChars;
  124. if (isEqual) { return child; }
  125. }
  126. },
  127. put: function(charSpec) {
  128. var state;
  129. // If the character specification already exists in a child of the current
  130. // state, just return that state.
  131. if (state = this.get(charSpec)) { return state; }
  132. // Make a new state for the character spec
  133. state = new State(charSpec);
  134. // Insert the new state as a child of the current state
  135. this.nextStates.push(state);
  136. // If this character specification repeats, insert the new state as a child
  137. // of itself. Note that this will not trigger an infinite loop because each
  138. // transition during recognition consumes a character.
  139. if (charSpec.repeat) {
  140. state.nextStates.push(state);
  141. }
  142. // Return the new state
  143. return state;
  144. },
  145. // Find a list of child states matching the next character
  146. match: function(ch) {
  147. // DEBUG "Processing `" + ch + "`:"
  148. var nextStates = this.nextStates,
  149. child, charSpec, chars;
  150. // DEBUG " " + debugState(this)
  151. var returned = [];
  152. for (var i=0, l=nextStates.length; i<l; i++) {
  153. child = nextStates[i];
  154. charSpec = child.charSpec;
  155. if (typeof (chars = charSpec.validChars) !== 'undefined') {
  156. if (chars.indexOf(ch) !== -1) { returned.push(child); }
  157. } else if (typeof (chars = charSpec.invalidChars) !== 'undefined') {
  158. if (chars.indexOf(ch) === -1) { returned.push(child); }
  159. }
  160. }
  161. return returned;
  162. }
  163. /** IF DEBUG
  164. , debug: function() {
  165. var charSpec = this.charSpec,
  166. debug = "[",
  167. chars = charSpec.validChars || charSpec.invalidChars;
  168. if (charSpec.invalidChars) { debug += "^"; }
  169. debug += chars;
  170. debug += "]";
  171. if (charSpec.repeat) { debug += "+"; }
  172. return debug;
  173. }
  174. END IF **/
  175. };
  176. /** IF DEBUG
  177. function debug(log) {
  178. console.log(log);
  179. }
  180. function debugState(state) {
  181. return state.nextStates.map(function(n) {
  182. if (n.nextStates.length === 0) { return "( " + n.debug() + " [accepting] )"; }
  183. return "( " + n.debug() + " <then> " + n.nextStates.map(function(s) { return s.debug() }).join(" or ") + " )";
  184. }).join(", ")
  185. }
  186. END IF **/
  187. // This is a somewhat naive strategy, but should work in a lot of cases
  188. // A better strategy would properly resolve /posts/:id/new and /posts/edit/:id.
  189. //
  190. // This strategy generally prefers more static and less dynamic matching.
  191. // Specifically, it
  192. //
  193. // * prefers fewer stars to more, then
  194. // * prefers using stars for less of the match to more, then
  195. // * prefers fewer dynamic segments to more, then
  196. // * prefers more static segments to more
  197. function sortSolutions(states) {
  198. return states.sort(function(a, b) {
  199. if (a.types.stars !== b.types.stars) { return a.types.stars - b.types.stars; }
  200. if (a.types.stars) {
  201. if (a.types.statics !== b.types.statics) { return b.types.statics - a.types.statics; }
  202. if (a.types.dynamics !== b.types.dynamics) { return b.types.dynamics - a.types.dynamics; }
  203. }
  204. if (a.types.dynamics !== b.types.dynamics) { return a.types.dynamics - b.types.dynamics; }
  205. if (a.types.statics !== b.types.statics) { return b.types.statics - a.types.statics; }
  206. return 0;
  207. });
  208. }
  209. function recognizeChar(states, ch) {
  210. var nextStates = [];
  211. for (var i=0, l=states.length; i<l; i++) {
  212. var state = states[i];
  213. nextStates = nextStates.concat(state.match(ch));
  214. }
  215. return nextStates;
  216. }
  217. var oCreate = Object.create || function(proto) {
  218. function F() {}
  219. F.prototype = proto;
  220. return new F();
  221. };
  222. function RecognizeResults(queryParams) {
  223. this.queryParams = queryParams || {};
  224. }
  225. RecognizeResults.prototype = oCreate({
  226. splice: Array.prototype.splice,
  227. slice: Array.prototype.slice,
  228. push: Array.prototype.push,
  229. length: 0,
  230. queryParams: null
  231. });
  232. function findHandler(state, path, queryParams) {
  233. var handlers = state.handlers, regex = state.regex;
  234. var captures = path.match(regex), currentCapture = 1;
  235. var result = new RecognizeResults(queryParams);
  236. for (var i=0, l=handlers.length; i<l; i++) {
  237. var handler = handlers[i], names = handler.names, params = {};
  238. for (var j=0, m=names.length; j<m; j++) {
  239. params[names[j]] = captures[currentCapture++];
  240. }
  241. result.push({ handler: handler.handler, params: params, isDynamic: !!names.length });
  242. }
  243. return result;
  244. }
  245. function addSegment(currentState, segment) {
  246. segment.eachChar(function(ch) {
  247. var state;
  248. currentState = currentState.put(ch);
  249. });
  250. return currentState;
  251. }
  252. // The main interface
  253. var RouteRecognizer = function() {
  254. this.rootState = new State();
  255. this.names = {};
  256. };
  257. RouteRecognizer.prototype = {
  258. add: function(routes, options) {
  259. var currentState = this.rootState, regex = "^",
  260. types = { statics: 0, dynamics: 0, stars: 0 },
  261. handlers = [], allSegments = [], name;
  262. var isEmpty = true;
  263. for (var i=0, l=routes.length; i<l; i++) {
  264. var route = routes[i], names = [];
  265. var segments = parse(route.path, names, types);
  266. allSegments = allSegments.concat(segments);
  267. for (var j=0, m=segments.length; j<m; j++) {
  268. var segment = segments[j];
  269. if (segment instanceof EpsilonSegment) { continue; }
  270. isEmpty = false;
  271. // Add a "/" for the new segment
  272. currentState = currentState.put({ validChars: "/" });
  273. regex += "/";
  274. // Add a representation of the segment to the NFA and regex
  275. currentState = addSegment(currentState, segment);
  276. regex += segment.regex();
  277. }
  278. var handler = { handler: route.handler, names: names };
  279. handlers.push(handler);
  280. }
  281. if (isEmpty) {
  282. currentState = currentState.put({ validChars: "/" });
  283. regex += "/";
  284. }
  285. currentState.handlers = handlers;
  286. currentState.regex = new RegExp(regex + "$");
  287. currentState.types = types;
  288. if (name = options && options.as) {
  289. this.names[name] = {
  290. segments: allSegments,
  291. handlers: handlers
  292. };
  293. }
  294. },
  295. handlersFor: function(name) {
  296. var route = this.names[name], result = [];
  297. if (!route) { throw new Error("There is no route named " + name); }
  298. for (var i=0, l=route.handlers.length; i<l; i++) {
  299. result.push(route.handlers[i]);
  300. }
  301. return result;
  302. },
  303. hasRoute: function(name) {
  304. return !!this.names[name];
  305. },
  306. generate: function(name, params) {
  307. var route = this.names[name], output = "";
  308. if (!route) { throw new Error("There is no route named " + name); }
  309. var segments = route.segments;
  310. for (var i=0, l=segments.length; i<l; i++) {
  311. var segment = segments[i];
  312. if (segment instanceof EpsilonSegment) { continue; }
  313. output += "/";
  314. output += segment.generate(params);
  315. }
  316. if (output.charAt(0) !== '/') { output = '/' + output; }
  317. if (params && params.queryParams) {
  318. output += this.generateQueryString(params.queryParams, route.handlers);
  319. }
  320. return output;
  321. },
  322. generateQueryString: function(params, handlers) {
  323. var pairs = [];
  324. var keys = [];
  325. for(var key in params) {
  326. if (params.hasOwnProperty(key)) {
  327. keys.push(key);
  328. }
  329. }
  330. keys.sort();
  331. for (var i = 0, len = keys.length; i < len; i++) {
  332. key = keys[i];
  333. var value = params[key];
  334. if (value == null) {
  335. continue;
  336. }
  337. var pair = key;
  338. if (isArray(value)) {
  339. for (var j = 0, l = value.length; j < l; j++) {
  340. var arrayPair = key + '[]' + '=' + encodeURIComponent(value[j]);
  341. pairs.push(arrayPair);
  342. }
  343. } else {
  344. pair += "=" + encodeURIComponent(value);
  345. pairs.push(pair);
  346. }
  347. }
  348. if (pairs.length === 0) { return ''; }
  349. return "?" + pairs.join("&");
  350. },
  351. parseQueryString: function(queryString) {
  352. var pairs = queryString.split("&"), queryParams = {};
  353. for(var i=0; i < pairs.length; i++) {
  354. var pair = pairs[i].split('='),
  355. key = decodeURIComponent(pair[0]),
  356. keyLength = key.length,
  357. isArray = false,
  358. value;
  359. if (pair.length === 1) {
  360. value = 'true';
  361. } else {
  362. //Handle arrays
  363. if (keyLength > 2 && key.slice(keyLength -2) === '[]') {
  364. isArray = true;
  365. key = key.slice(0, keyLength - 2);
  366. if(!queryParams[key]) {
  367. queryParams[key] = [];
  368. }
  369. }
  370. value = pair[1] ? decodeURIComponent(pair[1]) : '';
  371. }
  372. if (isArray) {
  373. queryParams[key].push(value);
  374. } else {
  375. queryParams[key] = decodeURIComponent(value);
  376. }
  377. }
  378. return queryParams;
  379. },
  380. recognize: function(path) {
  381. var states = [ this.rootState ],
  382. pathLen, i, l, queryStart, queryParams = {},
  383. isSlashDropped = false;
  384. path = decodeURI(path);
  385. queryStart = path.indexOf('?');
  386. if (queryStart !== -1) {
  387. var queryString = path.substr(queryStart + 1, path.length);
  388. path = path.substr(0, queryStart);
  389. queryParams = this.parseQueryString(queryString);
  390. }
  391. // DEBUG GROUP path
  392. if (path.charAt(0) !== "/") { path = "/" + path; }
  393. pathLen = path.length;
  394. if (pathLen > 1 && path.charAt(pathLen - 1) === "/") {
  395. path = path.substr(0, pathLen - 1);
  396. isSlashDropped = true;
  397. }
  398. for (i=0, l=path.length; i<l; i++) {
  399. states = recognizeChar(states, path.charAt(i));
  400. if (!states.length) { break; }
  401. }
  402. // END DEBUG GROUP
  403. var solutions = [];
  404. for (i=0, l=states.length; i<l; i++) {
  405. if (states[i].handlers) { solutions.push(states[i]); }
  406. }
  407. states = sortSolutions(solutions);
  408. var state = solutions[0];
  409. if (state && state.handlers) {
  410. // if a trailing slash was dropped and a star segment is the last segment
  411. // specified, put the trailing slash back
  412. if (isSlashDropped && state.regex.source.slice(-5) === "(.+)$") {
  413. path = path + "/";
  414. }
  415. return findHandler(state, path, queryParams);
  416. }
  417. }
  418. };
  419. __exports__.RouteRecognizer = RouteRecognizer;
  420. function Target(path, matcher, delegate) {
  421. this.path = path;
  422. this.matcher = matcher;
  423. this.delegate = delegate;
  424. }
  425. Target.prototype = {
  426. to: function(target, callback) {
  427. var delegate = this.delegate;
  428. if (delegate && delegate.willAddRoute) {
  429. target = delegate.willAddRoute(this.matcher.target, target);
  430. }
  431. this.matcher.add(this.path, target);
  432. if (callback) {
  433. if (callback.length === 0) { throw new Error("You must have an argument in the function passed to `to`"); }
  434. this.matcher.addChild(this.path, target, callback, this.delegate);
  435. }
  436. return this;
  437. }
  438. };
  439. function Matcher(target) {
  440. this.routes = {};
  441. this.children = {};
  442. this.target = target;
  443. }
  444. Matcher.prototype = {
  445. add: function(path, handler) {
  446. this.routes[path] = handler;
  447. },
  448. addChild: function(path, target, callback, delegate) {
  449. var matcher = new Matcher(target);
  450. this.children[path] = matcher;
  451. var match = generateMatch(path, matcher, delegate);
  452. if (delegate && delegate.contextEntered) {
  453. delegate.contextEntered(target, match);
  454. }
  455. callback(match);
  456. }
  457. };
  458. function generateMatch(startingPath, matcher, delegate) {
  459. return function(path, nestedCallback) {
  460. var fullPath = startingPath + path;
  461. if (nestedCallback) {
  462. nestedCallback(generateMatch(fullPath, matcher, delegate));
  463. } else {
  464. return new Target(startingPath + path, matcher, delegate);
  465. }
  466. };
  467. }
  468. function addRoute(routeArray, path, handler) {
  469. var len = 0;
  470. for (var i=0, l=routeArray.length; i<l; i++) {
  471. len += routeArray[i].path.length;
  472. }
  473. path = path.substr(len);
  474. var route = { path: path, handler: handler };
  475. routeArray.push(route);
  476. }
  477. function eachRoute(baseRoute, matcher, callback, binding) {
  478. var routes = matcher.routes;
  479. for (var path in routes) {
  480. if (routes.hasOwnProperty(path)) {
  481. var routeArray = baseRoute.slice();
  482. addRoute(routeArray, path, routes[path]);
  483. if (matcher.children[path]) {
  484. eachRoute(routeArray, matcher.children[path], callback, binding);
  485. } else {
  486. callback.call(binding, routeArray);
  487. }
  488. }
  489. }
  490. }
  491. RouteRecognizer.prototype.map = function(callback, addRouteCallback) {
  492. var matcher = new Matcher();
  493. callback(generateMatch("", matcher, this.delegate));
  494. eachRoute([], matcher, function(route) {
  495. if (addRouteCallback) { addRouteCallback(this, route); }
  496. else { this.add(route); }
  497. }, this);
  498. };
  499. })(window);