├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── gulpfile.js ├── index.js ├── jsdoc.json ├── karma.conf.js ├── lib ├── route.js └── route │ ├── compiled-grammar.js │ ├── grammar.js │ ├── nodes.js │ ├── parser.js │ └── visitors │ ├── create_visitor.js │ ├── reconstruct.js │ ├── regexp.js │ └── reverse.js ├── package.json ├── scripts └── compile_parser.js └── test ├── backbone-compatibility.js ├── test.js └── visitor.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | lib/route/compiled-grammar.js 3 | coverage/** 4 | docs/** 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "extends": "airbnb-base/legacy", 8 | "rules": { 9 | "func-names": 0, 10 | "no-param-reassign": 0, 11 | "vars-on-top": 0, 12 | "new-cap": 0, 13 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | lcov.info 3 | *.seed 4 | *.log 5 | *.csv 6 | *.dat 7 | *.out 8 | *.pid 9 | *.gz 10 | 11 | pids 12 | logs 13 | results 14 | build 15 | .grunt 16 | 17 | node_modules 18 | coverage 19 | docs 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | - "4" 5 | - "iojs" 6 | - "0.12" 7 | - "0.10" 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Ryan Sorensen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/rcs/route-parser.png?branch=master)](https://travis-ci.org/rcs/route-parser) 2 | [![Dependency Status](https://david-dm.org/rcs/route-parser.svg?theme=shields.io)](https://david-dm.org/rcs/route-parser) 3 | [![devDependency Status](https://david-dm.org/rcs/route-parser/dev-status.svg?theme=shields.io)](https://david-dm.org/rcs/route-parser#info=devDependencies) 4 | ## What is it? 5 | 6 | A isomorphic, bullet-proof, ninja-ready route parsing, matching, and reversing library for Javascript in Node and the browser. 7 | 8 | ## Is it any good? 9 | 10 | Yes. 11 | 12 | ## Why do I want it? 13 | 14 | You want to write routes in a way that makes sense, capture named parameters, add additional constraints to routing, and be able to generate links using your routes. You don't want to be surprised by limitations in your router or hit a spiral of additional complexity when you need to do more advanced tasks. 15 | 16 | 17 | ## How do I install it? 18 | 19 | ```Shell 20 | npm install --save route-parser 21 | ``` 22 | 23 | ## How do I use it? 24 | 25 | ```javascript 26 | Route = require('route-parser'); 27 | var route = new Route('/my/fancy/route/page/:page'); 28 | route.match('/my/fancy/route/page/7') // { page: 7 } 29 | route.reverse({page: 3}) // -> '/my/fancy/route/page/3' 30 | ``` 31 | ## What can I use in my routes? 32 | 33 | | Example | Description | 34 | | --------------- | -------- | 35 | | `:name` | a parameter to capture from the route up to `/`, `?`, or end of string | 36 | | `*splat` | a splat to capture from the route up to `?` or end of string | 37 | | `()` | Optional group that doesn't have to be part of the query. Can contain nested optional groups, params, and splats 38 | | anything else | free form literals | 39 | 40 | Some examples: 41 | 42 | * `/some/(optional/):thing` 43 | * `/users/:id/comments/:comment/rating/:rating` 44 | * `/*a/foo/*b` 45 | * `/books/*section/:title` 46 | * `/books?author=:author&subject=:subject` 47 | 48 | 49 | ## How does it work? 50 | 51 | We define a grammar for route specifications and parse the route. Matching is done by generating a regular expression from that tree, and reversing is done by filling in parameter nodes in the tree. 52 | 53 | 54 | 55 | 56 | ## FAQ 57 | ### Isn't this over engineered? A full parser for route specifications? 58 | Not really. Parsing route specs into regular expressions gets to be problematic if you want to do named captures and route reversing. Other routing libraries have issues with parsing one of `/foo(/:bar)` or `/foo(/:bar)`, and two-pass string-to-RegExp transforms become complex and error prone. 59 | 60 | Using a parser here also gives us the chance to give early feedback for any errors that are made in the route spec. 61 | 62 | ### Why not use... 63 | 64 | #### [RFC 6570 URI Templates](http://tools.ietf.org/html/rfc6570) directly? 65 | 66 | URI templates are designed for expanding data into a template, not matching a route. Taking an arbitrary path and matching it against a URI template isn't defined. In the expansion step of URI templates, undefined variables can be evaluated to `''`, which isn't useful when trying to do route matching, optional or otherwise. To use a URI-template-like language is possible, but needs to be expanded past the RFC 67 | 68 | ### [Express](http://expressjs.com/)/[Backbone.Router](http://backbonejs.org/docs/backbone.html#section-155)/[Director](https://github.com/flatiron/director) style routers 69 | 70 | These all lack named parameters and reversability. 71 | 72 | Named parameters are less brittle and reduce the coupling betwen routes and their handlers. Given the routes `/users/:userid/photos/:category` and `/photos/:category/users/:userid`, backbone style routing solutions require two different handlers. Named parameters let you use just one. 73 | 74 | Reversibility means you can use a single route table for your application for matching and generating links instead of throwing route helper functions throughout your code. 75 | 76 | 77 | ## Related 78 | 79 | * [rails/journey](http://github.com/rails/journey) 80 | * [url-pattern](http://github.com/snd/url-pattern) 81 | * [Director](https://github.com/flatiron/director) 82 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | 'use strict'; 4 | 5 | var gulp = require('gulp'); 6 | var childProcess = require('child_process'); 7 | var eslint = require('gulp-eslint'); 8 | 9 | var realCodePaths = [ 10 | '**/*.{js,jsx,coffee}', 11 | '!node_modules/**', 12 | '!lib/route/compiled-grammar.js', 13 | '!coverage/**', 14 | '!docs/**' 15 | ]; 16 | 17 | gulp.task('lint', function () { 18 | gulp.src(realCodePaths) 19 | .pipe(eslint()) 20 | .pipe(eslint.format()); 21 | }); 22 | 23 | gulp.task('jsdoc', function () { 24 | childProcess.exec( 25 | './node_modules/.bin/jsdoc -c jsdoc.json', 26 | function (error, stdout, stderr) { 27 | console.log(stdout); 28 | console.error(stderr); 29 | } 30 | ); 31 | }); 32 | 33 | gulp.task('default', function () { 34 | gulp.watch(realCodePaths, ['lint', 'jsdoc']); 35 | }); 36 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module Passage 3 | */ 4 | 5 | 'use strict'; 6 | 7 | var Route = require('./lib/route'); 8 | 9 | module.exports = Route; 10 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "include": ["lib", "index.js"], 4 | "exclude": ["./lib/route/compiled-grammar.js"] 5 | }, 6 | "opts": { 7 | "destination": "./docs/", 8 | "recurse": true, 9 | "private": true 10 | } 11 | } 12 | 13 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Sat Feb 15 2014 18:21:16 GMT-0800 (PST) 3 | 4 | 'use strict'; 5 | 6 | module.exports = function (config) { 7 | config.set({ 8 | 9 | // base path, that will be used to resolve files and exclude 10 | basePath: '', 11 | 12 | 13 | // frameworks to use 14 | frameworks: ['mocha'], 15 | 16 | 17 | // list of files / patterns to load in the browser 18 | files: [ 19 | 'node_modules/es5-shim/es5-shim.js', 20 | 'test/**/*.js' 21 | ], 22 | 23 | 24 | // list of files to exclude 25 | exclude: [ 26 | ], 27 | 28 | 29 | // test results reporter to use 30 | // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' 31 | reporters: ['progress'], 32 | preprocessors: { 33 | '**/*.js': ['webpack'] 34 | }, 35 | 36 | 37 | webpack: { 38 | cache: true, 39 | module: { 40 | loaders: [ 41 | // { test: /\.coffee$/, loader: 'coffee-loader' } 42 | ] 43 | } 44 | }, 45 | 46 | webpackServer: { 47 | stats: { 48 | colors: true 49 | } 50 | }, 51 | 52 | // web server port 53 | port: 9876, 54 | 55 | // enable / disable colors in the output (reporters and logs) 56 | colors: true, 57 | 58 | // level of logging 59 | // possible values: config.LOG_DISABLE || config.LOG_ERROR 60 | // || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 61 | logLevel: config.LOG_DEBUG, 62 | 63 | 64 | // enable / disable watching file and executing tests whenever any file changes 65 | autoWatch: true, 66 | 67 | 68 | // Start these browsers, currently available: 69 | // - Chrome 70 | // - ChromeCanary 71 | // - Firefox 72 | // - Opera (has to be installed with `npm install karma-opera-launcher`) 73 | // - Safari (only Mac; has to be installed with `npm install karma-safari-launcher`) 74 | // - PhantomJS 75 | // - IE (only Windows; has to be installed with `npm install karma-ie-launcher`) 76 | browsers: ['PhantomJS'], 77 | 78 | 79 | // If browser does not capture in given timeout [ms], kill it 80 | captureTimeout: 60000, 81 | 82 | 83 | // Continuous Integration mode 84 | // if true, it capture browsers, run tests and exit 85 | singleRun: false, 86 | 87 | plugins: [ 88 | 'karma-webpack', 89 | 'karma-mocha', 90 | 'karma-phantomjs-launcher' 91 | ] 92 | }); 93 | }; 94 | -------------------------------------------------------------------------------- /lib/route.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Parser = require('./route/parser'); 4 | var RegexpVisitor = require('./route/visitors/regexp'); 5 | var ReverseVisitor = require('./route/visitors/reverse'); 6 | 7 | 8 | /** 9 | * Represents a route 10 | * @example 11 | * var route = Route('/:foo/:bar'); 12 | * @example 13 | * var route = Route('/:foo/:bar'); 14 | * @param {string} spec - the string specification of the route. 15 | * use :param for single portion captures, *param for splat style captures, 16 | * and () for optional route branches 17 | * @constructor 18 | */ 19 | function Route(spec) { 20 | var route; 21 | if (this) { 22 | // constructor called with new 23 | route = this; 24 | } else { 25 | // constructor called as a function 26 | route = Object.create(Route.prototype); 27 | } 28 | if (typeof spec === 'undefined') { 29 | throw new Error('A route spec is required'); 30 | } 31 | route.spec = spec; 32 | route.ast = Parser.parse(spec); 33 | return route; 34 | } 35 | 36 | Route.prototype = Object.create(null); 37 | 38 | /** 39 | * Match a path against this route, returning the matched parameters if 40 | * it matches, false if not. 41 | * @example 42 | * var route = new Route('/this/is/my/route') 43 | * route.match('/this/is/my/route') // -> {} 44 | * @example 45 | * var route = new Route('/:one/:two') 46 | * route.match('/foo/bar/') // -> {one: 'foo', two: 'bar'} 47 | * @param {string} path the path to match this route against 48 | * @return {(Object.|false)} A map of the matched route 49 | * parameters, or false if matching failed 50 | */ 51 | Route.prototype.match = function (path) { 52 | var re = RegexpVisitor.visit(this.ast); 53 | var matched = re.match(path); 54 | 55 | return matched !== null ? matched : false; 56 | }; 57 | 58 | /** 59 | * Reverse a route specification to a path, returning false if it can't be 60 | * fulfilled 61 | * @example 62 | * var route = new Route('/:one/:two') 63 | * route.reverse({one: 'foo', two: 'bar'}) -> '/foo/bar' 64 | * @param {Object} params The parameters to fill in 65 | * @return {(String|false)} The filled in path 66 | */ 67 | Route.prototype.reverse = function (params) { 68 | return ReverseVisitor.visit(this.ast, params); 69 | }; 70 | 71 | 72 | module.exports = Route; 73 | -------------------------------------------------------------------------------- /lib/route/compiled-grammar.js: -------------------------------------------------------------------------------- 1 | /* parser generated by jison 0.4.17 */ 2 | /* 3 | Returns a Parser object of the following structure: 4 | 5 | Parser: { 6 | yy: {} 7 | } 8 | 9 | Parser.prototype: { 10 | yy: {}, 11 | trace: function(), 12 | symbols_: {associative list: name ==> number}, 13 | terminals_: {associative list: number ==> name}, 14 | productions_: [...], 15 | performAction: function anonymous(yytext, yyleng, yylineno, yy, yystate, $$, _$), 16 | table: [...], 17 | defaultActions: {...}, 18 | parseError: function(str, hash), 19 | parse: function(input), 20 | 21 | lexer: { 22 | EOF: 1, 23 | parseError: function(str, hash), 24 | setInput: function(input), 25 | input: function(), 26 | unput: function(str), 27 | more: function(), 28 | less: function(n), 29 | pastInput: function(), 30 | upcomingInput: function(), 31 | showPosition: function(), 32 | test_match: function(regex_match_array, rule_index), 33 | next: function(), 34 | lex: function(), 35 | begin: function(condition), 36 | popState: function(), 37 | _currentRules: function(), 38 | topState: function(), 39 | pushState: function(condition), 40 | 41 | options: { 42 | ranges: boolean (optional: true ==> token location info will include a .range[] member) 43 | flex: boolean (optional: true ==> flex-like lexing behaviour where the rules are tested exhaustively to find the longest match) 44 | backtrack_lexer: boolean (optional: true ==> lexer regexes are tested in order and for each matching regex the action code is invoked; the lexer terminates the scan when a token is returned by the action code) 45 | }, 46 | 47 | performAction: function(yy, yy_, $avoiding_name_collisions, YY_START), 48 | rules: [...], 49 | conditions: {associative list: name ==> set}, 50 | } 51 | } 52 | 53 | 54 | token location info (@$, _$, etc.): { 55 | first_line: n, 56 | last_line: n, 57 | first_column: n, 58 | last_column: n, 59 | range: [start_number, end_number] (where the numbers are indexes into the input string, regular zero-based) 60 | } 61 | 62 | 63 | the parseError function receives a 'hash' object with these members for lexer and parser errors: { 64 | text: (matched text) 65 | token: (the produced terminal token, if any) 66 | line: (yylineno) 67 | } 68 | while parser (grammar) errors will also provide these members, i.e. parser errors deliver a superset of attributes: { 69 | loc: (yylloc) 70 | expected: (string describing the set of expected tokens) 71 | recoverable: (boolean: TRUE when the parser has a error recovery rule available for this particular error) 72 | } 73 | */ 74 | var parser = (function(){ 75 | var o=function(k,v,o,l){for(o=o||{},l=k.length;l--;o[k[l]]=v);return o},$V0=[1,9],$V1=[1,10],$V2=[1,11],$V3=[1,12],$V4=[5,11,12,13,14,15]; 76 | var parser = {trace: function trace() { }, 77 | yy: {}, 78 | symbols_: {"error":2,"root":3,"expressions":4,"EOF":5,"expression":6,"optional":7,"literal":8,"splat":9,"param":10,"(":11,")":12,"LITERAL":13,"SPLAT":14,"PARAM":15,"$accept":0,"$end":1}, 79 | terminals_: {2:"error",5:"EOF",11:"(",12:")",13:"LITERAL",14:"SPLAT",15:"PARAM"}, 80 | productions_: [0,[3,2],[3,1],[4,2],[4,1],[6,1],[6,1],[6,1],[6,1],[7,3],[8,1],[9,1],[10,1]], 81 | performAction: function anonymous(yytext, yyleng, yylineno, yy, yystate /* action[1] */, $$ /* vstack */, _$ /* lstack */) { 82 | /* this == yyval */ 83 | 84 | var $0 = $$.length - 1; 85 | switch (yystate) { 86 | case 1: 87 | return new yy.Root({},[$$[$0-1]]) 88 | break; 89 | case 2: 90 | return new yy.Root({},[new yy.Literal({value: ''})]) 91 | break; 92 | case 3: 93 | this.$ = new yy.Concat({},[$$[$0-1],$$[$0]]); 94 | break; 95 | case 4: case 5: 96 | this.$ = $$[$0]; 97 | break; 98 | case 6: 99 | this.$ = new yy.Literal({value: $$[$0]}); 100 | break; 101 | case 7: 102 | this.$ = new yy.Splat({name: $$[$0]}); 103 | break; 104 | case 8: 105 | this.$ = new yy.Param({name: $$[$0]}); 106 | break; 107 | case 9: 108 | this.$ = new yy.Optional({},[$$[$0-1]]); 109 | break; 110 | case 10: 111 | this.$ = yytext; 112 | break; 113 | case 11: case 12: 114 | this.$ = yytext.slice(1); 115 | break; 116 | } 117 | }, 118 | table: [{3:1,4:2,5:[1,3],6:4,7:5,8:6,9:7,10:8,11:$V0,13:$V1,14:$V2,15:$V3},{1:[3]},{5:[1,13],6:14,7:5,8:6,9:7,10:8,11:$V0,13:$V1,14:$V2,15:$V3},{1:[2,2]},o($V4,[2,4]),o($V4,[2,5]),o($V4,[2,6]),o($V4,[2,7]),o($V4,[2,8]),{4:15,6:4,7:5,8:6,9:7,10:8,11:$V0,13:$V1,14:$V2,15:$V3},o($V4,[2,10]),o($V4,[2,11]),o($V4,[2,12]),{1:[2,1]},o($V4,[2,3]),{6:14,7:5,8:6,9:7,10:8,11:$V0,12:[1,16],13:$V1,14:$V2,15:$V3},o($V4,[2,9])], 119 | defaultActions: {3:[2,2],13:[2,1]}, 120 | parseError: function parseError(str, hash) { 121 | if (hash.recoverable) { 122 | this.trace(str); 123 | } else { 124 | function _parseError (msg, hash) { 125 | this.message = msg; 126 | this.hash = hash; 127 | } 128 | _parseError.prototype = Error; 129 | 130 | throw new _parseError(str, hash); 131 | } 132 | }, 133 | parse: function parse(input) { 134 | var self = this, stack = [0], tstack = [], vstack = [null], lstack = [], table = this.table, yytext = '', yylineno = 0, yyleng = 0, recovering = 0, TERROR = 2, EOF = 1; 135 | var args = lstack.slice.call(arguments, 1); 136 | var lexer = Object.create(this.lexer); 137 | var sharedState = { yy: {} }; 138 | for (var k in this.yy) { 139 | if (Object.prototype.hasOwnProperty.call(this.yy, k)) { 140 | sharedState.yy[k] = this.yy[k]; 141 | } 142 | } 143 | lexer.setInput(input, sharedState.yy); 144 | sharedState.yy.lexer = lexer; 145 | sharedState.yy.parser = this; 146 | if (typeof lexer.yylloc == 'undefined') { 147 | lexer.yylloc = {}; 148 | } 149 | var yyloc = lexer.yylloc; 150 | lstack.push(yyloc); 151 | var ranges = lexer.options && lexer.options.ranges; 152 | if (typeof sharedState.yy.parseError === 'function') { 153 | this.parseError = sharedState.yy.parseError; 154 | } else { 155 | this.parseError = Object.getPrototypeOf(this).parseError; 156 | } 157 | function popStack(n) { 158 | stack.length = stack.length - 2 * n; 159 | vstack.length = vstack.length - n; 160 | lstack.length = lstack.length - n; 161 | } 162 | var lex = function () { 163 | var token; 164 | token = lexer.lex() || EOF; 165 | if (typeof token !== 'number') { 166 | token = self.symbols_[token] || token; 167 | } 168 | return token; 169 | }; 170 | var symbol, preErrorSymbol, state, action, a, r, yyval = {}, p, len, newState, expected; 171 | while (true) { 172 | state = stack[stack.length - 1]; 173 | if (this.defaultActions[state]) { 174 | action = this.defaultActions[state]; 175 | } else { 176 | if (symbol === null || typeof symbol == 'undefined') { 177 | symbol = lex(); 178 | } 179 | action = table[state] && table[state][symbol]; 180 | } 181 | if (typeof action === 'undefined' || !action.length || !action[0]) { 182 | var errStr = ''; 183 | expected = []; 184 | for (p in table[state]) { 185 | if (this.terminals_[p] && p > TERROR) { 186 | expected.push('\'' + this.terminals_[p] + '\''); 187 | } 188 | } 189 | if (lexer.showPosition) { 190 | errStr = 'Parse error on line ' + (yylineno + 1) + ':\n' + lexer.showPosition() + '\nExpecting ' + expected.join(', ') + ', got \'' + (this.terminals_[symbol] || symbol) + '\''; 191 | } else { 192 | errStr = 'Parse error on line ' + (yylineno + 1) + ': Unexpected ' + (symbol == EOF ? 'end of input' : '\'' + (this.terminals_[symbol] || symbol) + '\''); 193 | } 194 | this.parseError(errStr, { 195 | text: lexer.match, 196 | token: this.terminals_[symbol] || symbol, 197 | line: lexer.yylineno, 198 | loc: yyloc, 199 | expected: expected 200 | }); 201 | } 202 | if (action[0] instanceof Array && action.length > 1) { 203 | throw new Error('Parse Error: multiple actions possible at state: ' + state + ', token: ' + symbol); 204 | } 205 | switch (action[0]) { 206 | case 1: 207 | stack.push(symbol); 208 | vstack.push(lexer.yytext); 209 | lstack.push(lexer.yylloc); 210 | stack.push(action[1]); 211 | symbol = null; 212 | if (!preErrorSymbol) { 213 | yyleng = lexer.yyleng; 214 | yytext = lexer.yytext; 215 | yylineno = lexer.yylineno; 216 | yyloc = lexer.yylloc; 217 | if (recovering > 0) { 218 | recovering--; 219 | } 220 | } else { 221 | symbol = preErrorSymbol; 222 | preErrorSymbol = null; 223 | } 224 | break; 225 | case 2: 226 | len = this.productions_[action[1]][1]; 227 | yyval.$ = vstack[vstack.length - len]; 228 | yyval._$ = { 229 | first_line: lstack[lstack.length - (len || 1)].first_line, 230 | last_line: lstack[lstack.length - 1].last_line, 231 | first_column: lstack[lstack.length - (len || 1)].first_column, 232 | last_column: lstack[lstack.length - 1].last_column 233 | }; 234 | if (ranges) { 235 | yyval._$.range = [ 236 | lstack[lstack.length - (len || 1)].range[0], 237 | lstack[lstack.length - 1].range[1] 238 | ]; 239 | } 240 | r = this.performAction.apply(yyval, [ 241 | yytext, 242 | yyleng, 243 | yylineno, 244 | sharedState.yy, 245 | action[1], 246 | vstack, 247 | lstack 248 | ].concat(args)); 249 | if (typeof r !== 'undefined') { 250 | return r; 251 | } 252 | if (len) { 253 | stack = stack.slice(0, -1 * len * 2); 254 | vstack = vstack.slice(0, -1 * len); 255 | lstack = lstack.slice(0, -1 * len); 256 | } 257 | stack.push(this.productions_[action[1]][0]); 258 | vstack.push(yyval.$); 259 | lstack.push(yyval._$); 260 | newState = table[stack[stack.length - 2]][stack[stack.length - 1]]; 261 | stack.push(newState); 262 | break; 263 | case 3: 264 | return true; 265 | } 266 | } 267 | return true; 268 | }}; 269 | /* generated by jison-lex 0.3.4 */ 270 | var lexer = (function(){ 271 | var lexer = ({ 272 | 273 | EOF:1, 274 | 275 | parseError:function parseError(str, hash) { 276 | if (this.yy.parser) { 277 | this.yy.parser.parseError(str, hash); 278 | } else { 279 | throw new Error(str); 280 | } 281 | }, 282 | 283 | // resets the lexer, sets new input 284 | setInput:function (input, yy) { 285 | this.yy = yy || this.yy || {}; 286 | this._input = input; 287 | this._more = this._backtrack = this.done = false; 288 | this.yylineno = this.yyleng = 0; 289 | this.yytext = this.matched = this.match = ''; 290 | this.conditionStack = ['INITIAL']; 291 | this.yylloc = { 292 | first_line: 1, 293 | first_column: 0, 294 | last_line: 1, 295 | last_column: 0 296 | }; 297 | if (this.options.ranges) { 298 | this.yylloc.range = [0,0]; 299 | } 300 | this.offset = 0; 301 | return this; 302 | }, 303 | 304 | // consumes and returns one char from the input 305 | input:function () { 306 | var ch = this._input[0]; 307 | this.yytext += ch; 308 | this.yyleng++; 309 | this.offset++; 310 | this.match += ch; 311 | this.matched += ch; 312 | var lines = ch.match(/(?:\r\n?|\n).*/g); 313 | if (lines) { 314 | this.yylineno++; 315 | this.yylloc.last_line++; 316 | } else { 317 | this.yylloc.last_column++; 318 | } 319 | if (this.options.ranges) { 320 | this.yylloc.range[1]++; 321 | } 322 | 323 | this._input = this._input.slice(1); 324 | return ch; 325 | }, 326 | 327 | // unshifts one char (or a string) into the input 328 | unput:function (ch) { 329 | var len = ch.length; 330 | var lines = ch.split(/(?:\r\n?|\n)/g); 331 | 332 | this._input = ch + this._input; 333 | this.yytext = this.yytext.substr(0, this.yytext.length - len); 334 | //this.yyleng -= len; 335 | this.offset -= len; 336 | var oldLines = this.match.split(/(?:\r\n?|\n)/g); 337 | this.match = this.match.substr(0, this.match.length - 1); 338 | this.matched = this.matched.substr(0, this.matched.length - 1); 339 | 340 | if (lines.length - 1) { 341 | this.yylineno -= lines.length - 1; 342 | } 343 | var r = this.yylloc.range; 344 | 345 | this.yylloc = { 346 | first_line: this.yylloc.first_line, 347 | last_line: this.yylineno + 1, 348 | first_column: this.yylloc.first_column, 349 | last_column: lines ? 350 | (lines.length === oldLines.length ? this.yylloc.first_column : 0) 351 | + oldLines[oldLines.length - lines.length].length - lines[0].length : 352 | this.yylloc.first_column - len 353 | }; 354 | 355 | if (this.options.ranges) { 356 | this.yylloc.range = [r[0], r[0] + this.yyleng - len]; 357 | } 358 | this.yyleng = this.yytext.length; 359 | return this; 360 | }, 361 | 362 | // When called from action, caches matched text and appends it on next action 363 | more:function () { 364 | this._more = true; 365 | return this; 366 | }, 367 | 368 | // When called from action, signals the lexer that this rule fails to match the input, so the next matching rule (regex) should be tested instead. 369 | reject:function () { 370 | if (this.options.backtrack_lexer) { 371 | this._backtrack = true; 372 | } else { 373 | return this.parseError('Lexical error on line ' + (this.yylineno + 1) + '. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true).\n' + this.showPosition(), { 374 | text: "", 375 | token: null, 376 | line: this.yylineno 377 | }); 378 | 379 | } 380 | return this; 381 | }, 382 | 383 | // retain first n characters of the match 384 | less:function (n) { 385 | this.unput(this.match.slice(n)); 386 | }, 387 | 388 | // displays already matched input, i.e. for error messages 389 | pastInput:function () { 390 | var past = this.matched.substr(0, this.matched.length - this.match.length); 391 | return (past.length > 20 ? '...':'') + past.substr(-20).replace(/\n/g, ""); 392 | }, 393 | 394 | // displays upcoming input, i.e. for error messages 395 | upcomingInput:function () { 396 | var next = this.match; 397 | if (next.length < 20) { 398 | next += this._input.substr(0, 20-next.length); 399 | } 400 | return (next.substr(0,20) + (next.length > 20 ? '...' : '')).replace(/\n/g, ""); 401 | }, 402 | 403 | // displays the character position where the lexing error occurred, i.e. for error messages 404 | showPosition:function () { 405 | var pre = this.pastInput(); 406 | var c = new Array(pre.length + 1).join("-"); 407 | return pre + this.upcomingInput() + "\n" + c + "^"; 408 | }, 409 | 410 | // test the lexed token: return FALSE when not a match, otherwise return token 411 | test_match:function (match, indexed_rule) { 412 | var token, 413 | lines, 414 | backup; 415 | 416 | if (this.options.backtrack_lexer) { 417 | // save context 418 | backup = { 419 | yylineno: this.yylineno, 420 | yylloc: { 421 | first_line: this.yylloc.first_line, 422 | last_line: this.last_line, 423 | first_column: this.yylloc.first_column, 424 | last_column: this.yylloc.last_column 425 | }, 426 | yytext: this.yytext, 427 | match: this.match, 428 | matches: this.matches, 429 | matched: this.matched, 430 | yyleng: this.yyleng, 431 | offset: this.offset, 432 | _more: this._more, 433 | _input: this._input, 434 | yy: this.yy, 435 | conditionStack: this.conditionStack.slice(0), 436 | done: this.done 437 | }; 438 | if (this.options.ranges) { 439 | backup.yylloc.range = this.yylloc.range.slice(0); 440 | } 441 | } 442 | 443 | lines = match[0].match(/(?:\r\n?|\n).*/g); 444 | if (lines) { 445 | this.yylineno += lines.length; 446 | } 447 | this.yylloc = { 448 | first_line: this.yylloc.last_line, 449 | last_line: this.yylineno + 1, 450 | first_column: this.yylloc.last_column, 451 | last_column: lines ? 452 | lines[lines.length - 1].length - lines[lines.length - 1].match(/\r?\n?/)[0].length : 453 | this.yylloc.last_column + match[0].length 454 | }; 455 | this.yytext += match[0]; 456 | this.match += match[0]; 457 | this.matches = match; 458 | this.yyleng = this.yytext.length; 459 | if (this.options.ranges) { 460 | this.yylloc.range = [this.offset, this.offset += this.yyleng]; 461 | } 462 | this._more = false; 463 | this._backtrack = false; 464 | this._input = this._input.slice(match[0].length); 465 | this.matched += match[0]; 466 | token = this.performAction.call(this, this.yy, this, indexed_rule, this.conditionStack[this.conditionStack.length - 1]); 467 | if (this.done && this._input) { 468 | this.done = false; 469 | } 470 | if (token) { 471 | return token; 472 | } else if (this._backtrack) { 473 | // recover context 474 | for (var k in backup) { 475 | this[k] = backup[k]; 476 | } 477 | return false; // rule action called reject() implying the next rule should be tested instead. 478 | } 479 | return false; 480 | }, 481 | 482 | // return next match in input 483 | next:function () { 484 | if (this.done) { 485 | return this.EOF; 486 | } 487 | if (!this._input) { 488 | this.done = true; 489 | } 490 | 491 | var token, 492 | match, 493 | tempMatch, 494 | index; 495 | if (!this._more) { 496 | this.yytext = ''; 497 | this.match = ''; 498 | } 499 | var rules = this._currentRules(); 500 | for (var i = 0; i < rules.length; i++) { 501 | tempMatch = this._input.match(this.rules[rules[i]]); 502 | if (tempMatch && (!match || tempMatch[0].length > match[0].length)) { 503 | match = tempMatch; 504 | index = i; 505 | if (this.options.backtrack_lexer) { 506 | token = this.test_match(tempMatch, rules[i]); 507 | if (token !== false) { 508 | return token; 509 | } else if (this._backtrack) { 510 | match = false; 511 | continue; // rule action called reject() implying a rule MISmatch. 512 | } else { 513 | // else: this is a lexer rule which consumes input without producing a token (e.g. whitespace) 514 | return false; 515 | } 516 | } else if (!this.options.flex) { 517 | break; 518 | } 519 | } 520 | } 521 | if (match) { 522 | token = this.test_match(match, rules[index]); 523 | if (token !== false) { 524 | return token; 525 | } 526 | // else: this is a lexer rule which consumes input without producing a token (e.g. whitespace) 527 | return false; 528 | } 529 | if (this._input === "") { 530 | return this.EOF; 531 | } else { 532 | return this.parseError('Lexical error on line ' + (this.yylineno + 1) + '. Unrecognized text.\n' + this.showPosition(), { 533 | text: "", 534 | token: null, 535 | line: this.yylineno 536 | }); 537 | } 538 | }, 539 | 540 | // return next match that has a token 541 | lex:function lex() { 542 | var r = this.next(); 543 | if (r) { 544 | return r; 545 | } else { 546 | return this.lex(); 547 | } 548 | }, 549 | 550 | // activates a new lexer condition state (pushes the new lexer condition state onto the condition stack) 551 | begin:function begin(condition) { 552 | this.conditionStack.push(condition); 553 | }, 554 | 555 | // pop the previously active lexer condition state off the condition stack 556 | popState:function popState() { 557 | var n = this.conditionStack.length - 1; 558 | if (n > 0) { 559 | return this.conditionStack.pop(); 560 | } else { 561 | return this.conditionStack[0]; 562 | } 563 | }, 564 | 565 | // produce the lexer rule set which is active for the currently active lexer condition state 566 | _currentRules:function _currentRules() { 567 | if (this.conditionStack.length && this.conditionStack[this.conditionStack.length - 1]) { 568 | return this.conditions[this.conditionStack[this.conditionStack.length - 1]].rules; 569 | } else { 570 | return this.conditions["INITIAL"].rules; 571 | } 572 | }, 573 | 574 | // return the currently active lexer condition state; when an index argument is provided it produces the N-th previous condition state, if available 575 | topState:function topState(n) { 576 | n = this.conditionStack.length - 1 - Math.abs(n || 0); 577 | if (n >= 0) { 578 | return this.conditionStack[n]; 579 | } else { 580 | return "INITIAL"; 581 | } 582 | }, 583 | 584 | // alias for begin(condition) 585 | pushState:function pushState(condition) { 586 | this.begin(condition); 587 | }, 588 | 589 | // return the number of states currently on the stack 590 | stateStackSize:function stateStackSize() { 591 | return this.conditionStack.length; 592 | }, 593 | options: {}, 594 | performAction: function anonymous(yy,yy_,$avoiding_name_collisions,YY_START) { 595 | var YYSTATE=YY_START; 596 | switch($avoiding_name_collisions) { 597 | case 0:return "("; 598 | break; 599 | case 1:return ")"; 600 | break; 601 | case 2:return "SPLAT"; 602 | break; 603 | case 3:return "PARAM"; 604 | break; 605 | case 4:return "LITERAL"; 606 | break; 607 | case 5:return "LITERAL"; 608 | break; 609 | case 6:return "EOF"; 610 | break; 611 | } 612 | }, 613 | rules: [/^(?:\()/,/^(?:\))/,/^(?:\*+\w+)/,/^(?::+\w+)/,/^(?:[\w%\-~\n]+)/,/^(?:.)/,/^(?:$)/], 614 | conditions: {"INITIAL":{"rules":[0,1,2,3,4,5,6],"inclusive":true}} 615 | }); 616 | return lexer; 617 | })(); 618 | parser.lexer = lexer; 619 | function Parser () { 620 | this.yy = {}; 621 | } 622 | Parser.prototype = parser;parser.Parser = Parser; 623 | return new Parser; 624 | })(); 625 | 626 | 627 | if (typeof require !== 'undefined' && typeof exports !== 'undefined') { 628 | exports.parser = parser; 629 | exports.Parser = parser.Parser; 630 | exports.parse = function () { return parser.parse.apply(parser, arguments); }; 631 | } -------------------------------------------------------------------------------- /lib/route/grammar.js: -------------------------------------------------------------------------------- 1 | /** @module route/grammar */ 2 | 3 | 'use strict'; 4 | 5 | /** 6 | * Helper for jison grammar definitions, altering "new" calls to the 7 | * yy namepsace and assigning action results to "$$" 8 | * @example 9 | * o('foo', 'new Lllama($1)') // -> ['foo', '$$ = new yy.Llama($1)'] 10 | * @param {String} patternString a jison BNF pattern string 11 | * @param {String} action the javascript code to execute against the pattern 12 | * @return {Array.} the expression strings to give to jison 13 | * @private 14 | */ 15 | function o(patternString, action) { 16 | if (typeof action === 'undefined') { 17 | return [patternString, '$$ = $1;']; 18 | } 19 | 20 | action = action.replace(/\bnew /g, '$&yy.'); 21 | return [patternString, '$$ = ' + action + ';']; 22 | } 23 | 24 | 25 | module.exports = { 26 | /* eslint-disable no-multi-spaces */ 27 | lex: { 28 | rules: [ 29 | ['\\(', 'return "(";'], 30 | ['\\)', 'return ")";'], 31 | ['\\*+\\w+', 'return "SPLAT";'], 32 | [':+\\w+', 'return "PARAM";'], 33 | ['[\\w%\\-~\\n]+', 'return "LITERAL";'], 34 | ['.', 'return "LITERAL";'], 35 | ['$', 'return "EOF";'] 36 | ] 37 | }, 38 | bnf: { 39 | root: [ 40 | ['expressions EOF', 'return new yy.Root({},[$1])'], 41 | ['EOF', 'return new yy.Root({},[new yy.Literal({value: \'\'})])'] 42 | ], 43 | expressions: [ 44 | o('expressions expression', 'new Concat({},[$1,$2])'), 45 | o('expression') 46 | ], 47 | expression: [ 48 | o('optional'), 49 | o('literal', 'new Literal({value: $1})'), 50 | o('splat', 'new Splat({name: $1})'), 51 | o('param', 'new Param({name: $1})') 52 | ], 53 | optional: [ 54 | o('( expressions )', 'new Optional({},[$2])') 55 | ], 56 | literal: [ 57 | o('LITERAL', 'yytext') 58 | ], 59 | splat: [ 60 | o('SPLAT', 'yytext.slice(1)') 61 | ], 62 | param: [ 63 | o('PARAM', 'yytext.slice(1)') 64 | ] 65 | }, 66 | startSymbol: 'root' 67 | /* eslint-enable no-multi-spaces */ 68 | }; 69 | -------------------------------------------------------------------------------- /lib/route/nodes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** @module route/nodes */ 4 | 5 | 6 | /** 7 | * Create a node for use with the parser, giving it a constructor that takes 8 | * props, children, and returns an object with props, children, and a 9 | * displayName. 10 | * @param {String} displayName The display name for the node 11 | * @return {{displayName: string, props: Object, children: Array}} 12 | */ 13 | function createNode(displayName) { 14 | return function (props, children) { 15 | return { 16 | displayName: displayName, 17 | props: props, 18 | children: children || [] 19 | }; 20 | }; 21 | } 22 | 23 | module.exports = { 24 | Root: createNode('Root'), 25 | Concat: createNode('Concat'), 26 | Literal: createNode('Literal'), 27 | Splat: createNode('Splat'), 28 | Param: createNode('Param'), 29 | Optional: createNode('Optional') 30 | }; 31 | -------------------------------------------------------------------------------- /lib/route/parser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module route/parser 3 | */ 4 | 5 | 'use strict'; 6 | 7 | /** Wrap the compiled parser with the context to create node objects */ 8 | var parser = require('./compiled-grammar').parser; 9 | parser.yy = require('./nodes'); 10 | module.exports = parser; 11 | -------------------------------------------------------------------------------- /lib/route/visitors/create_visitor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @module route/visitors/create_visitor 5 | */ 6 | 7 | var nodeTypes = Object.keys(require('../nodes')); 8 | 9 | /** 10 | * Helper for creating visitors. Take an object of node name to handler 11 | * mappings, returns an object with a "visit" method that can be called 12 | * @param {Object.} handlers A mapping of node 13 | * type to visitor functions 14 | * @return {{visit: function(node,context)}} A visitor object with a "visit" 15 | * method that can be called on a node with a context 16 | */ 17 | function createVisitor(handlers) { 18 | nodeTypes.forEach(function (nodeType) { 19 | if (typeof handlers[nodeType] === 'undefined') { 20 | throw new Error('No handler defined for ' + nodeType.displayName); 21 | } 22 | }); 23 | 24 | return { 25 | /** 26 | * Call the given handler for this node type 27 | * @param {Object} node the AST node 28 | * @param {Object} context context to pass through to handlers 29 | * @return {Object} 30 | */ 31 | visit: function (node, context) { 32 | return this.handlers[node.displayName].call(this, node, context); 33 | }, 34 | handlers: handlers 35 | }; 36 | } 37 | 38 | module.exports = createVisitor; 39 | -------------------------------------------------------------------------------- /lib/route/visitors/reconstruct.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var createVisitor = require('./create_visitor'); 4 | 5 | /** 6 | * Visitor for the AST to reconstruct the normalized input 7 | * @class ReconstructVisitor 8 | * @borrows Visitor-visit 9 | */ 10 | var ReconstructVisitor = createVisitor({ 11 | Concat: function (node) { 12 | return node.children 13 | .map(function (child) { 14 | return this.visit(child); 15 | }.bind(this)) 16 | .join(''); 17 | }, 18 | 19 | Literal: function (node) { 20 | return node.props.value; 21 | }, 22 | 23 | Splat: function (node) { 24 | return '*' + node.props.name; 25 | }, 26 | 27 | Param: function (node) { 28 | return ':' + node.props.name; 29 | }, 30 | 31 | Optional: function (node) { 32 | return '(' + this.visit(node.children[0]) + ')'; 33 | }, 34 | 35 | Root: function (node) { 36 | return this.visit(node.children[0]); 37 | } 38 | }); 39 | 40 | module.exports = ReconstructVisitor; 41 | -------------------------------------------------------------------------------- /lib/route/visitors/regexp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var createVisitor = require('./create_visitor'); 4 | var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; 5 | 6 | /** 7 | * @class 8 | * @private 9 | */ 10 | function Matcher(options) { 11 | this.captures = options.captures; 12 | this.re = options.re; 13 | } 14 | 15 | /** 16 | * Try matching a path against the generated regular expression 17 | * @param {String} path The path to try to match 18 | * @return {Object|false} matched parameters or false 19 | */ 20 | Matcher.prototype.match = function (path) { 21 | var match = this.re.exec(path); 22 | var matchParams = {}; 23 | 24 | if (!match) { 25 | return false; 26 | } 27 | 28 | this.captures.forEach(function (capture, i) { 29 | if (typeof match[i + 1] === 'undefined') { 30 | matchParams[capture] = undefined; 31 | } else { 32 | matchParams[capture] = decodeURIComponent(match[i + 1]); 33 | } 34 | }); 35 | 36 | return matchParams; 37 | }; 38 | 39 | /** 40 | * Visitor for the AST to create a regular expression matcher 41 | * @class RegexpVisitor 42 | * @borrows Visitor-visit 43 | */ 44 | var RegexpVisitor = createVisitor({ 45 | Concat: function (node) { 46 | return node.children 47 | .reduce( 48 | function (memo, child) { 49 | var childResult = this.visit(child); 50 | return { 51 | re: memo.re + childResult.re, 52 | captures: memo.captures.concat(childResult.captures) 53 | }; 54 | }.bind(this), 55 | { re: '', captures: [] } 56 | ); 57 | }, 58 | Literal: function (node) { 59 | return { 60 | re: node.props.value.replace(escapeRegExp, '\\$&'), 61 | captures: [] 62 | }; 63 | }, 64 | 65 | Splat: function (node) { 66 | return { 67 | re: '([^?]*?)', 68 | captures: [node.props.name] 69 | }; 70 | }, 71 | 72 | Param: function (node) { 73 | return { 74 | re: '([^\\/\\?]+)', 75 | captures: [node.props.name] 76 | }; 77 | }, 78 | 79 | Optional: function (node) { 80 | var child = this.visit(node.children[0]); 81 | return { 82 | re: '(?:' + child.re + ')?', 83 | captures: child.captures 84 | }; 85 | }, 86 | 87 | Root: function (node) { 88 | var childResult = this.visit(node.children[0]); 89 | return new Matcher({ 90 | re: new RegExp('^' + childResult.re + '(?=\\?|$)'), 91 | captures: childResult.captures 92 | }); 93 | } 94 | }); 95 | 96 | module.exports = RegexpVisitor; 97 | -------------------------------------------------------------------------------- /lib/route/visitors/reverse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var createVisitor = require('./create_visitor'); 4 | 5 | /** 6 | * Visitor for the AST to construct a path with filled in parameters 7 | * @class ReverseVisitor 8 | * @borrows Visitor-visit 9 | */ 10 | var ReverseVisitor = createVisitor({ 11 | Concat: function (node, context) { 12 | var childResults = node.children 13 | .map(function (child) { 14 | return this.visit(child, context); 15 | }.bind(this)); 16 | 17 | if (childResults.some(function (c) { return c === false; })) { 18 | return false; 19 | } 20 | return childResults.join(''); 21 | }, 22 | 23 | Literal: function (node) { 24 | return decodeURI(node.props.value); 25 | }, 26 | 27 | Splat: function (node, context) { 28 | if (typeof context[node.props.name] === 'undefined') { 29 | return false; 30 | } 31 | return context[node.props.name]; 32 | }, 33 | 34 | Param: function (node, context) { 35 | if (typeof context[node.props.name] === 'undefined') { 36 | return false; 37 | } 38 | return context[node.props.name]; 39 | }, 40 | 41 | Optional: function (node, context) { 42 | var childResult = this.visit(node.children[0], context); 43 | if (childResult) { 44 | return childResult; 45 | } 46 | 47 | return ''; 48 | }, 49 | 50 | Root: function (node, context) { 51 | context = context || {}; 52 | var childResult = this.visit(node.children[0], context); 53 | if (childResult === false || typeof childResult === 'undefined') { 54 | return false; 55 | } 56 | return encodeURI(childResult); 57 | } 58 | }); 59 | 60 | module.exports = ReverseVisitor; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "route-parser", 3 | "version": "0.0.5", 4 | "description": "A isomorphic, bullet-proof, ninja-ready route parsing, matching, and reversing library for Javascript in Node and the browser. ", 5 | "keywords": [ 6 | "url", 7 | "matching", 8 | "routing", 9 | "route", 10 | "regex", 11 | "match" 12 | ], 13 | "homepage": "http://github.com/rcs/route-parser", 14 | "author": { 15 | "name": "Ryan Sorensen", 16 | "email": "rcsorensen@gmail.com", 17 | "url": "https://github.com/rcs" 18 | }, 19 | "main": "index.js", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/rcs/route-parser.git" 23 | }, 24 | "bugs": { 25 | "url": "http://github.com/rcs/route-parser/issues", 26 | "email": "rcsorensen@gmail.com" 27 | }, 28 | "engines": { 29 | "node": ">= 0.9" 30 | }, 31 | "directories": { 32 | "test": "test" 33 | }, 34 | "scripts": { 35 | "test": "mocha && karma start --singleRun", 36 | "test-node": "mocha", 37 | "test-client": "karma start --singleRun", 38 | "compile-parser": "scripts/compile_parser.js", 39 | "clean-compile": "rm ./lib/route/compiled-grammar.js", 40 | "lint": "eslint ." 41 | }, 42 | "license": "MIT", 43 | "devDependencies": { 44 | "chai": "~3.5.0", 45 | "es5-shim": "~4.5.7", 46 | "eslint": "^3.7.1", 47 | "eslint-config-airbnb": "^12.0.0", 48 | "eslint-config-airbnb-base": "^8.0.0", 49 | "eslint-plugin-import": "^1.16.0", 50 | "gulp": "~3.9.0", 51 | "gulp-eslint": "^3.0.1", 52 | "gulp-exec": "~2.1.2", 53 | "jison": "~0.4.17", 54 | "jison-lex": "~0.3.4", 55 | "jsdoc": "~3.4.0", 56 | "karma": "~0.13.22", 57 | "karma-mocha": "~0.2.2", 58 | "karma-phantomjs-launcher": "~1.0.0", 59 | "karma-webpack": "~1.7.0", 60 | "mocha": "~2.4.5", 61 | "phantomjs-prebuilt": "^2.1.6", 62 | "webpack": "~1.12.14", 63 | "webpack-dev-server": "~1.14.1" 64 | }, 65 | "dependencies": {} 66 | } 67 | -------------------------------------------------------------------------------- /scripts/compile_parser.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var fs = require('fs'); 3 | var path = require('path'); 4 | 5 | var jison = require('jison'); 6 | var Lexer = require('jison-lex'); 7 | var grammar = require('../lib/route/grammar.js'); 8 | var parser = new jison.Parser(grammar); 9 | 10 | // eslint-disable-next-line no-underscore-dangle 11 | parser.lexer = new Lexer(grammar.lex, null, grammar.terminals_); 12 | 13 | // Remove _token_stack label manually until fixed in jison: 14 | // https://github.com/zaach/jison/issues/351 15 | // https://github.com/zaach/jison/pull/352 16 | var compiledGrammar = parser.generate({ moduleType: 'js' }).replace(/_token_stack:\s?/, ''); 17 | 18 | 19 | fs.writeFileSync( 20 | path.join(__dirname, '/../lib/route/compiled-grammar.js'), 21 | [ 22 | compiledGrammar, 23 | "\n\n\nif (typeof require !== 'undefined' && typeof exports !== 'undefined') {", 24 | '\nexports.parser = parser;', 25 | '\nexports.Parser = parser.Parser;', 26 | '\nexports.parse = function () { return parser.parse.apply(parser, arguments); };', 27 | '\n}' 28 | ].join('') 29 | ); 30 | -------------------------------------------------------------------------------- /test/backbone-compatibility.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | 'use strict'; 4 | 5 | var assert = require('chai').assert; 6 | var RouteParser = require('../lib/route'); 7 | 8 | /* Route, path, expected params, options {name, reversed} */ 9 | var backboneTestCases = [ 10 | [ 11 | 'search/:query', 12 | 'search/news', 13 | { query: 'news' }, 14 | { name: 'simple' } 15 | ], 16 | [ 17 | 'search/:query', 18 | 'search/тест', 19 | { query: 'тест' }, 20 | { name: 'simple with unicode', reversed: 'search/%D1%82%D0%B5%D1%81%D1%82' } 21 | ], 22 | [ 23 | 'search/:query/p:page', 24 | 'search/nyc/p10', 25 | { query: 'nyc', page: '10' }, 26 | { name: 'two part' } 27 | ], 28 | [ 29 | 'splat/*args/end', 30 | 'splat/long-list/of/splatted_99args/end', 31 | { args: 'long-list/of/splatted_99args' }, 32 | { name: 'splats' } 33 | ], 34 | [ 35 | ':repo/compare/*from...*to', 36 | 'backbone/compare/1.0...braddunbar:with/slash', 37 | { repo: 'backbone', from: '1.0', to: 'braddunbar:with/slash' }, 38 | { name: 'complicated mixed' } 39 | ], 40 | [ 41 | 'optional(/:item)', 42 | 'optional', 43 | { item: undefined }, 44 | { name: 'optional' } 45 | ], 46 | [ 47 | 'optional(/:item)', 48 | 'optional/thing', 49 | { item: 'thing' }, 50 | { name: 'optional with param' } 51 | ], 52 | [ 53 | '*first/complex-*part/*rest', 54 | 'one/two/three/complex-part/four/five/six/seven', 55 | { first: 'one/two/three', part: 'part', rest: 'four/five/six/seven' }, 56 | { name: 'complex' } 57 | ], 58 | [ 59 | '*first/complex-*part/*rest', 60 | 'has%2Fslash/complex-has%23hash/has%20space', 61 | { first: 'has/slash', part: 'has#hash', rest: 'has space' }, 62 | { 63 | name: 'backbone#967 decodes encoded values', 64 | reversed: 'has/slash/complex-has#hash/has%20space' 65 | } 66 | ], 67 | [ 68 | '*anything', 69 | 'doesnt-match-a-route', 70 | { anything: 'doesnt-match-a-route' }, 71 | { name: 'anything' } 72 | ], 73 | [ 74 | 'decode/:named/*splat', 75 | 'decode/a%2Fb/c%2Fd/e', 76 | { named: 'a/b', splat: 'c/d/e' }, 77 | { name: 'decode named parameters, not splats', reversed: 'decode/a/b/c/d/e' } 78 | ], 79 | [ 80 | 'charñ', 81 | 'char%C3%B1', 82 | false, 83 | { name: '#2666 - Hashes with UTF8 in them.', reversed: 'char%C3%B1' } 84 | ], 85 | [ 86 | 'charñ', 87 | 'charñ', 88 | {}, 89 | { name: '#2666 - Hashes with UTF8 in them.', reversed: 'char%C3%B1' } 90 | ], 91 | [ 92 | 'char%C3%B1', 93 | 'charñ', 94 | false, 95 | { name: '#2666 - Hashes with UTF8 in them.', reversed: 'char%C3%B1' } 96 | ], 97 | [ 98 | 'char%C3%B1', 99 | 'char%C3%B1', 100 | {}, 101 | { name: '#2666 - Hashes with UTF8 in them.', reversed: 'char%C3%B1' } 102 | ], 103 | [ 104 | '', 105 | '', 106 | {}, 107 | { name: 'Allows empty route' } 108 | ], 109 | [ 110 | 'named/optional/(y:z)', 111 | 'named/optional/y', 112 | false, 113 | { name: 'doesn\'t match an unfulfilled optional route' } 114 | ], 115 | [ 116 | 'some/(optional/):thing', 117 | 'some/foo', 118 | { thing: 'foo' }, 119 | { 120 | name: 'backbone#1980 optional with trailing slash', 121 | reversed: 'some/optional/foo' 122 | } 123 | ], 124 | [ 125 | 'some/(optional/):thing', 126 | 'some/optional/foo', 127 | { thing: 'foo' }, 128 | { name: 'backbone#1980 optional with trailing slash' } 129 | ], 130 | [ 131 | 'myyjä', 132 | 'myyjä', 133 | {}, 134 | { name: 'unicode pathname', reversed: 'myyj%C3%A4' } 135 | ], 136 | [ 137 | 'stuff\nnonsense', 138 | 'stuff\nnonsense?param=foo%0Abar', 139 | {}, 140 | { name: 'newline in route', reversed: 'stuff%0Anonsense' } 141 | ] 142 | ].map(function (testCase) { 143 | var routeSpec = testCase[0]; 144 | var path = testCase[1]; 145 | var captured = testCase[2]; 146 | var name = testCase[3].name; 147 | var reversed = testCase[3].reversed || testCase[1]; 148 | 149 | return function () { 150 | it(testCase[3].name, function () { 151 | var route = new RouteParser(routeSpec); 152 | assert.deepEqual(route.match(path), captured); 153 | }); 154 | /* Only reverse routes we expected to succeed */ 155 | if (captured) { 156 | it('reverses ' + name, function () { 157 | var route = RouteParser(routeSpec); 158 | assert.equal(route.reverse(captured), reversed); 159 | }); 160 | } 161 | }; 162 | }); 163 | 164 | describe('Backbone route compatibility', function () { 165 | for (var i = 0; i < backboneTestCases.length; i++) { 166 | backboneTestCases[i](); 167 | } 168 | }); 169 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | 'use strict'; 4 | 5 | var assert = require('chai').assert; 6 | var RouteParser = require('../'); 7 | 8 | 9 | describe('Route', function () { 10 | it('should create', function () { 11 | assert.ok(RouteParser('/foo')); 12 | }); 13 | 14 | it('should create with new', function () { 15 | assert.ok(new RouteParser('/foo')); 16 | }); 17 | 18 | it('should have proper prototype', function () { 19 | var routeInstance = new RouteParser('/foo'); 20 | assert.ok(routeInstance instanceof RouteParser); 21 | }); 22 | 23 | it('should throw on no spec', function () { 24 | assert.throw(function () { RouteParser(); }, Error, /spec is required/); 25 | }); 26 | 27 | describe('basic', function () { 28 | it('should match /foo with a path of /foo', function () { 29 | var route = RouteParser('/foo'); 30 | assert.ok(route.match('/foo')); 31 | }); 32 | 33 | it('should match /foo with a path of /foo?query', function () { 34 | var route = RouteParser('/foo'); 35 | assert.ok(route.match('/foo?query')); 36 | }); 37 | 38 | it('shouldn\'t match /foo with a path of /bar/foo', function () { 39 | var route = RouteParser('/foo'); 40 | assert.notOk(route.match('/bar/foo')); 41 | }); 42 | 43 | it('shouldn\'t match /foo with a path of /foobar', function () { 44 | var route = RouteParser('/foo'); 45 | assert.notOk(route.match('/foobar')); 46 | }); 47 | 48 | it('shouldn\'t match /foo with a path of /bar', function () { 49 | var route = RouteParser('/foo'); 50 | assert.notOk(route.match('/bar')); 51 | }); 52 | }); 53 | 54 | describe('basic parameters', function () { 55 | it('should match /users/:id with a path of /users/1', function () { 56 | var route = RouteParser('/users/:id'); 57 | assert.ok(route.match('/users/1')); 58 | }); 59 | 60 | it('should not match /users/:id with a path of /users/', function () { 61 | var route = RouteParser('/users/:id'); 62 | assert.notOk(route.match('/users/')); 63 | }); 64 | 65 | it('should match /users/:id with a path of /users/1 and get parameters', function () { 66 | var route = RouteParser('/users/:id'); 67 | assert.deepEqual(route.match('/users/1'), { id: '1' }); 68 | }); 69 | 70 | it('should match deep pathing and get parameters', function () { 71 | var route = RouteParser('/users/:id/comments/:comment/rating/:rating'); 72 | assert.deepEqual(route.match('/users/1/comments/cats/rating/22222'), { id: '1', comment: 'cats', rating: '22222' }); 73 | }); 74 | }); 75 | 76 | describe('splat parameters', function () { 77 | it('should handle double splat parameters', function () { 78 | var route = RouteParser('/*a/foo/*b'); 79 | assert.deepEqual(route.match('/zoo/woo/foo/bar/baz'), { a: 'zoo/woo', b: 'bar/baz' }); 80 | }); 81 | }); 82 | 83 | describe('mixed', function () { 84 | it('should handle mixed splat and named parameters', function () { 85 | var route = RouteParser('/books/*section/:title'); 86 | assert.deepEqual( 87 | route.match('/books/some/section/last-words-a-memoir'), 88 | { section: 'some/section', title: 'last-words-a-memoir' } 89 | ); 90 | }); 91 | }); 92 | 93 | describe('optional', function () { 94 | it('should allow and match optional routes', function () { 95 | var route = RouteParser('/users/:id(/style/:style)'); 96 | assert.deepEqual(route.match('/users/3'), { id: '3', style: undefined }); 97 | }); 98 | 99 | it('should allow and match optional routes', function () { 100 | var route = RouteParser('/users/:id(/style/:style)'); 101 | assert.deepEqual(route.match('/users/3/style/pirate'), { id: '3', style: 'pirate' }); 102 | }); 103 | 104 | it('allows optional branches that start with a word character', function () { 105 | var route = RouteParser('/things/(option/:first)'); 106 | assert.deepEqual(route.match('/things/option/bar'), { first: 'bar' }); 107 | }); 108 | 109 | describe('nested', function () { 110 | it('allows nested', function () { 111 | var route = RouteParser('/users/:id(/style/:style(/more/:param))'); 112 | var result = route.match('/users/3/style/pirate'); 113 | var expected = { id: '3', style: 'pirate', param: undefined }; 114 | assert.deepEqual(result, expected); 115 | }); 116 | 117 | it('fetches the correct params from nested', function () { 118 | var route = RouteParser('/users/:id(/style/:style(/more/:param))'); 119 | assert.deepEqual(route.match('/users/3/style/pirate/more/things'), { id: '3', style: 'pirate', param: 'things' }); 120 | }); 121 | }); 122 | }); 123 | 124 | describe('reverse', function () { 125 | it('reverses routes without params', function () { 126 | var route = RouteParser('/foo'); 127 | assert.equal(route.reverse(), '/foo'); 128 | }); 129 | 130 | it('reverses routes with simple params', function () { 131 | var route = RouteParser('/:foo/:bar'); 132 | assert.equal(route.reverse({ foo: '1', bar: '2' }), '/1/2'); 133 | }); 134 | 135 | it('reverses routes with optional params', function () { 136 | var route = RouteParser('/things/(option/:first)'); 137 | assert.equal(route.reverse({ first: 'bar' }), '/things/option/bar'); 138 | }); 139 | 140 | it('reverses routes with unfilled optional params', function () { 141 | var route = RouteParser('/things/(option/:first)'); 142 | assert.equal(route.reverse(), '/things/'); 143 | }); 144 | 145 | it('reverses routes with optional params that can\'t fulfill the optional branch', function () { 146 | var route = RouteParser('/things/(option/:first(/second/:second))'); 147 | assert.equal(route.reverse({ second: 'foo' }), '/things/'); 148 | }); 149 | 150 | it('returns false for routes that can\'t be fulfilled', function () { 151 | var route = RouteParser('/foo/:bar'); 152 | assert.equal(route.reverse({}), false); 153 | }); 154 | 155 | it('returns false for routes with splat params that can\'t be fulfilled', function () { 156 | var route = RouteParser('/foo/*bar'); 157 | assert.equal(route.reverse({}), false); 158 | }); 159 | 160 | // https://git.io/vPBaA 161 | it('allows reversing falsy valued params', function () { 162 | var path = '/account/json/wall/post/:id/comments/?start=:start&max=:max'; 163 | var vars = { 164 | id: 50, 165 | start: 0, 166 | max: 12 167 | }; 168 | assert.equal( 169 | RouteParser(path).reverse(vars), 170 | '/account/json/wall/post/50/comments/?start=0&max=12' 171 | ); 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /test/visitor.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | 'use strict'; 4 | 5 | var assert = require('chai').assert; 6 | var createVisitor = require('../lib/route/visitors/create_visitor'); 7 | 8 | function sillyVisitor(node) { 9 | return node.displayName; 10 | } 11 | 12 | 13 | describe('createVisitor', function () { 14 | it('should throw if not all handler node types are defined', function () { 15 | assert.throw(function () { 16 | createVisitor({ Root: function () {} }); 17 | }, 18 | /No handler defined/ 19 | ); 20 | }); 21 | 22 | it('should create when all handlers are defined', function () { 23 | var visitor = createVisitor({ 24 | Root: function (node) { return 'Root(' + this.visit(node.children[0]) + ')'; }, 25 | Concat: function (node) { 26 | return 'Concat(' + node.children 27 | .map(function (child) { 28 | return this.visit(child); 29 | }.bind(this)) 30 | .join(' ') + ')'; 31 | }, 32 | Optional: function (node) { return 'Optional(' + this.visit(node.children[0]) + ')'; }, 33 | Literal: sillyVisitor, 34 | Splat: sillyVisitor, 35 | Param: sillyVisitor 36 | }); 37 | 38 | assert.ok(visitor); 39 | }); 40 | }); 41 | --------------------------------------------------------------------------------