UNPKG

grapnel.js

Version:

The first (and smallest!) JavaScript Router with PushState, Middleware, and Named Parameter support

256 lines (218 loc) 8.51 kB
/**** * Grapnel * https://github.com/baseprime/grapnel * * @author Greg Sabia Tucker <greg@narrowlabs.com> * @link http://basepri.me * * Released under MIT License. See LICENSE.txt or http://opensource.org/licenses/MIT */ import { EventEmitter } from 'events'; import Route, { ParsedRoute } from './route'; class Grapnel extends EventEmitter { static MiddlewareStack: typeof MiddlewareStack; static Route: typeof Route; _maxListeners: number = Infinity; state: MiddlewareStack; options: GrapnelOptions = {}; defaults: any = { root: '', target: ('object' === typeof window) ? window : {}, isWindow: ('object' === typeof window), pushState: false, hashBang: false } constructor(options?: GrapnelOptions) { super(); this.options = Object.assign({}, this.defaults, options); if ('object' === typeof this.options.target && 'function' === typeof this.options.target.addEventListener) { this.options.target.addEventListener('hashchange', () => { this.emit('hashchange'); }); this.options.target.addEventListener('popstate', (e: any) => { // Make sure popstate doesn't run on init -- this is a common issue with Safari and old versions of Chrome if (this.state && this.state.previousState === null) return false; this.emit('navigate'); }); } } add(routePath: string & RegExp): Grapnel { let middleware: Function[] = Array.prototype.slice.call(arguments, 1, -1); let handler: Function = Array.prototype.slice.call(arguments, -1)[0]; let fullPath = this.options.root + routePath; let route = new Route(fullPath); let routeHandler = (function () { // Build request parameters let req: ParsedRoute = route.parse(this.path()); // Check if matches are found if (req.match) { // Match found let extra = { req, route: fullPath, params: req.params, regex: req.match }; // Create call stack -- add middleware first, then handler let stack = new MiddlewareStack(this, extra).enqueue(middleware.concat(handler)); // emit main event this.emit('match', stack, req); // Continue? if (!stack.runCallback) return this; // Previous state becomes current state stack.previousState = this.state; // Save new state this.state = stack; // Prevent this handler from being called if parent handler in stack has instructed not to propagate any more events if (stack.parent() && stack.parent().propagateEvent === false) { stack.propagateEvent = false; return this; } // Call handler stack.callback(); } // Returns self return this; }).bind(this); // Event name let eventName = (!this.options.pushState && this.options.isWindow) ? 'hashchange' : 'navigate'; // Invoke when route is defined, and once again when app navigates return routeHandler().on(eventName, routeHandler); } get(): Grapnel { return this.add.apply(this, arguments); } trigger(): Grapnel { return this.emit.apply(this, arguments); } bind(): Grapnel { // Backwards compatibility with older versions which mimed jQuery's bind() return this.on.apply(this, arguments); } context(context: string & RegExp): () => Grapnel { let middleware = Array.prototype.slice.call(arguments, 1); return (...args: any[]) => { let value = args[0]; let subMiddleware = (args.length > 2) ? Array.prototype.slice.call(args, 1, -1) : []; let handler = Array.prototype.slice.call(args, -1)[0]; let prefix = (context.slice(-1) !== '/' && value !== '/' && value !== '') ? context + '/' : context; let path = (value.substr(0, 1) !== '/') ? value : value.substr(1); let pattern = prefix + path; return this.add.apply(this, [pattern].concat(middleware).concat(subMiddleware).concat([handler])); } } navigate(path: string, options: NavigateOptions): Grapnel { this.path(path, options).emit('navigate'); return this; } path(pathname?: string, options: NavigateOptions = {}) { let root = this.options.target; let frag = undefined; let pageName = options.title; if ('string' === typeof pathname) { // Set path if (this.options.pushState && 'function' === typeof root.history.pushState) { let state = options.state || root.history.state; frag = (this.options.root) ? (this.options.root + pathname) : pathname; root.history.pushState(state, pageName, frag); } else if (root.location) { let _frag = (this.options.root) ? (this.options.root + pathname) : pathname; root.location.hash = (this.options.hashBang ? '!' : '') + _frag; } else { root.pathname = pathname || ''; } return this; } else if ('undefined' === typeof pathname) { // Get path return (root.location && root.location.pathname) ? root.location.pathname : (root.pathname || ''); } else if (pathname === false) { // Clear path if (this.options.pushState && 'function' === typeof root.history.pushState) { let state = options.state || root.history.state; root.history.pushState(state, pageName, this.options.root || '/'); } else if (root.location) { root.location.hash = (this.options.hashBang) ? '!' : ''; } return this; } } static listen(...args: any[]): Grapnel { let opts: any; let routes: any; if (args[0] && args[1]) { opts = args[0]; routes = args[1]; } else { routes = args[0]; } // Return a new Grapnel instance return (function () { // TODO: Accept multi-level routes for (let key in routes) { this.add.call(this, key, routes[key]); } return this; }).call(new Grapnel(opts || {})); } static toString() { return this.name; } } class MiddlewareStack { stack: any[]; router: Grapnel; runCallback: boolean = true; callbackRan: boolean = true; propagateEvent: boolean = true; value: string; req: any; previousState: any; timeStamp: Number; static global: any[] = []; constructor(router: Grapnel, extendObj?: any) { this.stack = MiddlewareStack.global.slice(0); this.router = router; this.value = router.path(); Object.assign(this, extendObj); return this; } preventDefault() { this.runCallback = false; } stopPropagation() { this.propagateEvent = false; } parent() { let hasParentEvents = !!(this.previousState && this.previousState.value && this.previousState.value == this.value); return (hasParentEvents) ? this.previousState : false; } callback() { this.callbackRan = true; this.timeStamp = Date.now(); this.next(); } enqueue(handler: any, atIndex?: number) { let handlers = (!Array.isArray(handler)) ? [handler] : ((atIndex < handler.length) ? handler.reverse() : handler); while (handlers.length) { this.stack.splice(atIndex || this.stack.length + 1, 0, handlers.shift()); } return this; } next() { return this.stack.shift().call(this.router, this.req, this, () => this.next()); } } export interface GrapnelOptions { pushState?: boolean; hashBang?: boolean; isWindow?: boolean; target?: any; root?: string; } export interface NavigateOptions { title?: string; state?: any; } Grapnel.MiddlewareStack = MiddlewareStack; Grapnel.Route = Route; exports = module.exports = Grapnel;