diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16d8d68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build/ +node_modules/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c61923e --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +OUT := build + +package := ./package.json +deps := ./node_modules +deps_installed := $(deps)/installed + +js_entry := reititin.js + +browserify := $(deps)/browserify/bin/cmd.js + +$(OUT): + mkdir $(OUT) + +$(deps_installed): $(package) + npm install --silent + touch $@ + +$(OUT)/reititin.js: $(js_entry) $(deps_installed) | $(OUT) + $(browserify) $(js_entry) --standalone Reititin -o $@ + +$(OUT)/reititin.min.js: $(js_entry) $(deps_installed) | $(OUT) + $(browserify) $(js_entry) -g uglifyify --standalone Reititin -o $@ + +dist: $(OUT)/reititin.js $(OUT)/reititin.min.js + +.PHONY: clean +clean: + rm -rf $(OUT) + +.DEFAULT_GOAL := dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8f9dbf --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +# reititin - sane routing library + +reititin (finnish for 'router') is a small library to do essential routing. +In my search for a router, I couldn't find one that matches all of the following +criterias: + +- does both route mathching and route reversal +- match context is passed to routing function +- handles query string +- is packaged sanely (usable from Browserify without hacks) +- is a library, not a framework or global context eating monstrosity + +reititin tries to do all the above. + +## Installation + +If you use browserify or webpack + +``` +npm install reititin +``` + +If you use require.js or want to use it standalone, then standalone version is +available in [Releases](https://github.com/freiksenet/reititin/releases). + +If you want to build from source + +``` +git clone https://github.com/freiksenet/reititin.git +cd reititin +make +``` + +`build/reititin.(min.)js` will be created. + +## Usage + +Examples are using CommonJS modules, just substitute require statements with +your favourite ~~poison~~module system or alternatively window.Reititin. + +`Reititin.Router` is the main constructor function, that accepts object, whose +keys are route definitions and values are callbacks to be called on succesful +routing. + +All routes in Reititin have unique names. Reititin can get route in 3 different +ways: + +1. By getting an array of a name and a callback function as a routing function. +2. By getting name of a named function as a routing function +3. Using full route definition + +```js +var Reititin = require('reititin'); + +var routes = { + // Route with named function, name is 'routes' + '/route': function routes (match) {}, + // Route with explicit name, name is 'route', :id is parameter + '/route/:id': ['route', function (match) {}], + // Route with url name, name is '/route/good' + '/route/good': function (match) {}, + // Route with *splat, matches url fragment + '/splat/*splat': function splat (match) {}, + // Route with (optional) fragment + '/optional(/thing)': function option (match) {} +}; + +// Creating router +var router = new Reititin.Router(routes); + +// Optional catch all match +routes['*'] = function (match) {}; +var routerWithDefault = new Reititin.Router(routes); +``` + +### `Router.match(url) + +Tries to match the url againts the router rules. + +If router fails to match, and there is no catch-all rule, the method will return +false. If router matches, then method will return match object. Match object has +4 fields, `name` is route name, `url` is full url that was matched, `params` is +object of all url paramaters and their value and, finally, `query` is a parsed +querystring. + +```js +router.match('/ueou'); +// ==> false + +routerWithDefault.match('/ueoe'); +// ==> {name: 'default', url: '/ueoe', params: {}, query: {}} + +router.match('/route'); +// ==> {name: 'routes', url: '/route', params: {}, query: {}} + +router.match('/route/5'); +// ==> {name: 'route', url: '/route/5', params: {id: 5}, query: {}} + +router.match('/route/5?foo=baz&gar=brak'); +// ==> {name: 'route', url: '/route/5?foo=baz&gar=brak', params: {id: 5}}, query: {foo: baz, gar: brak}} + +router.match('/splat/foo/bar/baz'); +// ==> {name: 'splat', url: '/splat/foo/bar/baz', params: {splat: 'foo/bar/baz'}, query: {}} + +``` + +### `Router.route(url)` + +Tries to match the route with Route.match and calls the given callback with +match object, if match is successful. + +If router can't match, throws an error. If router matches, returns match object. + +```js +router.route('/ueou'); +// ==> throws error + +router.route('/route'); +// ==> {name: 'routes', url: '/route', params: {}, query: {}} +``` + +### Router.find(name, params, query) + +If there is a named route by name that matches given params, returns the route +reconstructed url with querystring matching the query. Returns false if no match +is found. + +```js +router.find('ueauo'); // ==> false +router.find('route', {foo: 'bar'}); // ==> false +router.find('routes'); // ==> '/route' +router.find('route', {id: 5}); // ==> '/route/5' +router.find('routes', {}, {foo: [5, 2], bar: 'foo'}); // ==> '/route?foo=5&foo=2&bar=foo +router.find('/route/good'); // ==> '/route/good' +``` + +You can always pass full url definition, even if route has proper name + +```js +router.find('/route/:id', {id: 5}); // ==> '/route/5' +``` + +### Router.reverse(name, params, query) + +Same as Router.find, but throws an error, if there is no match. Use this method +for reversing the urls that will be consumed as is (for `href` in anchor, +$.ajax, etc.), so that browser won't go to '/false'. + +```js +$.getJSON(router.reverse('routes')); +``` + +### Router.add(name, route, callback) + +Add a new route with given `name`, `route` and `callback`. Returns router itself +for chaining. It will override existing routes if there is one with both name +*or* route definition. + +### Router.remove(name) + +Removes a route, `name` is either a route name or route definition. Removes +matching both by name and route definition. + +## Hooking up the real life - HashRouter + +`Reititin.HashRouter` is a wrapper around `Reititin.Router` that uses +document.location.hash urls. It has same interfarce as normal router, but there +are 3 extra methods in HashRouter. + +### HashRouter.start() + +Binds `window.onhashchange` event, will try to route when hash changes. Also +tries to route with current url, if there is no url after hash, then '/' is +assumed. + +### HashRouter.stop() + +Unbinds the `window.onhashchange` event. + +### HashRouter.navigate(name, params, query) + +Reverses the url with given name, params and query and then changes page has to +it. + +## Different real life + +History API (aka pushState) and server-side routers are coming. diff --git a/package.json b/package.json new file mode 100644 index 0000000..b6dfa81 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "reititin", + "version": "0.1.0", + "author": "Mikhail Novikov", + "description": "Sane routing library", + "keywords": ["router", "url", "route", "browser"], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/freiksenet/reititin.git" + }, + "dependencies": { + "lodash": "2.4.1", + "route-parser": "0.0.2", + "query-string": "^0.4.1" + }, + "devDependencies": { + "browserify": "3.41.x", + "uglifyify": "^2.5.0" + }, + "main": "./reititin.js" +} diff --git a/reititin.js b/reititin.js new file mode 100644 index 0000000..53906e1 --- /dev/null +++ b/reititin.js @@ -0,0 +1,204 @@ +"use strict"; + +var qs = require('query-string'); +var Route = require('route-parser'); + +var isArray = Array.isArray || function (arg) { + return Object.prototype.toString.call(arg) === '[object Array]'; +}; + +var inherits = function (ctor, superCtor) { + ctor.super_ = superCtor; + ctor.prototype = Object.create(superCtor.prototype, { + constructor: { + value: ctor, + enumerable: false, + writable: true, + configurable: true + } + }); +}; + +var Router = function (routes) { + this.names = {}; + this.routes = {}; + this.defaultRoute = undefined; + + if (routes) { + for (var route in routes) { + var routeName; + var routeCallback = routes[route]; + + if (isArray(routeCallback)) { + routeName = routeCallback[0]; + routeCallback = routeCallback[1]; + } else if (routeCallback.name.length > 0) { + routeName = routeCallback.name; + } else { + routeName = route; + } + + this.add(routeName, route, routeCallback); + } + } +}; + +Router.prototype._getByName = function (name) { + return this.routes[this.names[name]]; +}; + +Router.prototype.add = function (name, route, callback) { + if (route === '*') { + this.defaultRoute = { + name: 'default', + matcher: new Route('*url'), + callback: callback + }; + } else { + this.remove(name); + this.remove(route); + + this.names[name] = route; + this.routes[route] = { + name: name, + matcher: new Route(route), + callback: callback + }; + } + + return this; +}; + +Router.prototype.remove = function (name) { + if (name === '*') { + this.defaultRoute = undefined; + } else if (this.names[name]) { + var route = this.names[name]; + delete this.routes[route]; + delete this.names[name]; + } else if (this.routes[name]) { + var routeName = this.routes[name].name; + delete this.names[routeName]; + delete this.routes[name]; + } + + return this; +}; + +function matchOne (route, url) { + var matcher = route.matcher; + var match = matcher.match(url); + if (match !== false) { + var query = {}; + var parts = url.split('?'); + if (parts.length > 1) { + query = qs.parse(parts[1]); + } + return { + name: route.name, + url: url, + params: match, + query: query + }; + } else { + return false; + } +} + +Router.prototype.match = function (url) { + for (var routeKey in this.routes) { + var route = this.routes[routeKey]; + var result = matchOne(route, url); + if (result !== false) { + return result; + } + } + if (this.defaultRoute) { + var defaultMatch = matchOne(this.defaultRoute, url); + defaultMatch.params = {}; + return defaultMatch; + } + return false; +}; + +Router.prototype.route = function (url) { + var match = this.match(url); + if (match !== false) { + this._getByName(match.name).callback(match); + return match; + } else { + throw "Couldn't match url '" + url + "'."; + } +}; + +Router.prototype.find = function (name, params, query) { + var rule = this._getByName(name); + if (!rule) { + rule = this.routes[name] || {}; + } + + var matcher = rule.matcher; + if (matcher) { + var match = matcher.reverse(params); + var queryString = ""; + if (query !== undefined) { + queryString = qs.stringify(query); + } + if (queryString.length > 0) { + match = match + "?" + queryString; + } + return match; + } else { + return false; + } +}; + +Router.prototype.reverse = function (name, params, query) { + var url = this.find(name, params, query); + if (url !== false) { + return url; + } else { + throw "Couldn't reverse " + name + " with given params " + params + "."; + } +}; + +var HashRouter = function (routes) { + HashRouter.super_.call(this, routes); + this.started = false; +}; + +inherits(HashRouter, Router); + +HashRouter.prototype.navigate = function (name, params, query) { + window.location.hash = '#' + this.reverse(name, params, query); +}; + +HashRouter.prototype.start = function () { + if (!this.started) { + this.started = true; + this.listener = function () { + var location = window.location.hash; + if (window.location.hash.length > 0) { + location = location.slice(1); + } else { + location = '/'; + } + this.route(location); + }.bind(this); + window.addEventListener('hashchange', this.listener); + this.listener(); + } +}; + +HashRouter.prototype.end = function () { + if (this.started) { + window.removeEventListener('hashchange', this.listener); + delete this.listener; + this.started = false; + } +}; + +module.exports = { + Router: Router, + HashRouter: HashRouter +};