├── .npmignore ├── .gitignore ├── src ├── api.js ├── main.js ├── StateWithParams.js ├── anchors.js ├── util.js ├── Transition.js ├── State.js └── Router.js ├── .babelrc ├── tsconfig.json ├── test ├── unitTests.html ├── integrationTests.html ├── lib │ ├── qunit.css │ └── qunit.js ├── integration-tests.js └── unit-tests.js ├── Gruntfile.js ├── MIT-LICENSE.txt ├── package.json ├── abyssa.d.ts ├── README.md └── global └── abyssa.js /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | commonjs 3 | es -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | 2 | /* Represents the public API of the last instanciated router; Useful to break circular dependencies between router and its states */ 3 | const api = {} 4 | export default api 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": { 4 | "presets": ["es2015"] 5 | }, 6 | "es": { 7 | "presets": [ 8 | ["es2015-no-commonjs"] 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import * as util from './util' 2 | import Router from './Router' 3 | import api from './api' 4 | 5 | const State = util.stateShorthand 6 | 7 | export { 8 | Router, 9 | api, 10 | State, 11 | util 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "noImplicitAny": true 5 | }, 6 | "compileOnSave": false, 7 | "atom": { 8 | "rewriteTsconfig": false 9 | }, 10 | "filesGlob": [ 11 | "abyssa.d.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /test/unitTests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Abyssa unit tests 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test/integrationTests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Abyssa integration tests 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | 3 | grunt.initConfig({ 4 | 5 | connect: { 6 | server: { 7 | options: { 8 | base: '', 9 | port: 9999 10 | } 11 | } 12 | }, 13 | 14 | qunit: { 15 | all: { 16 | options: { 17 | urls: [ 18 | 'http://127.0.0.1:9999/test/unitTests.html', 19 | 'http://127.0.0.1:9999/test/integrationTests.html' 20 | ] 21 | } 22 | } 23 | } 24 | }); 25 | 26 | grunt.loadNpmTasks('grunt-contrib-connect'); 27 | grunt.loadNpmTasks('grunt-contrib-qunit'); 28 | 29 | grunt.registerTask('test', ['connect', 'qunit']); 30 | }; 31 | -------------------------------------------------------------------------------- /MIT-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Alexandre Galays 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/StateWithParams.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Creates a new StateWithParams instance. 3 | * 4 | * StateWithParams is the merge between a State object (created and added to the router before init) 5 | * and params (both path and query params, extracted from the URL after init) 6 | * 7 | * This is an internal model The public model is the asPublic property. 8 | */ 9 | export default function StateWithParams(state, params, pathQuery, diff) { 10 | return { 11 | state, 12 | params, 13 | toString, 14 | asPublic: makePublicAPI(state, params, pathQuery, diff) 15 | } 16 | } 17 | 18 | function makePublicAPI(state, params, pathQuery, paramsDiff) { 19 | 20 | /* 21 | * Returns whether this state or any of its parents has the given fullName. 22 | */ 23 | function isIn(fullStateName) { 24 | let current = state 25 | while (current) { 26 | if (current.fullName == fullStateName) return true 27 | current = current.parent 28 | } 29 | return false 30 | } 31 | 32 | return { 33 | uri: pathQuery, 34 | params, 35 | paramsDiff, 36 | name: state ? state.name : '', 37 | fullName: state ? state.fullName : '', 38 | data: state ? state.data : {}, 39 | isIn 40 | } 41 | } 42 | 43 | function toString() { 44 | const name = this.state && this.state.fullName 45 | return name + ':' + JSON.stringify(this.params) 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "abyssa", 3 | "description": "Hierarchical router for single page applications", 4 | "keywords": ["routes", "routing", "router", "hierarchical", "stateful", "pushState", "typescript"], 5 | "homepage": "https://github.com/AlexGalays/abyssa-js/", 6 | "version": "8.0.8", 7 | "author": { 8 | "name": "Alexandre Galays", 9 | "url": "https://github.com/AlexGalays/" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/AlexGalays/abyssa-js.git" 14 | }, 15 | "main": "commonjs/main.js", 16 | "module": "es/main.js", 17 | "typings": "abyssa.d.ts", 18 | 19 | "licenses": [{ 20 | "type": "MIT", 21 | "url": "http://www.opensource.org/licenses/mit-license.php" 22 | }], 23 | "devDependencies": { 24 | "browserify": "10.2.4", 25 | "babelify": "7.2.0", 26 | "babel-cli": "6.4.5", 27 | "babel-preset-es2015": "6.24.0", 28 | "babel-preset-es2015-no-commonjs": "0.0.2", 29 | "grunt": "0.4.5", 30 | "grunt-cli": "0.1.11", 31 | "grunt-contrib-connect": "0.8.0", 32 | "grunt-contrib-qunit": "1.3.0" 33 | }, 34 | "scripts": { 35 | "build": "npm run build-commonjs && npm run build-es && npm run build-global", 36 | "build-es": "BABEL_ENV=es node node_modules/babel-cli/bin/babel.js src --out-dir es", 37 | "build-commonjs": "BABEL_ENV=commonjs node node_modules/babel-cli/bin/babel.js src --out-dir commonjs", 38 | "build-global": "BABEL_ENV=commonjs node node_modules/browserify/bin/cmd.js src/main.js -s Abyssa -o global/abyssa.js -t babelify", 39 | "test": "npm run build-global && grunt test" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /abyssa.d.ts: -------------------------------------------------------------------------------- 1 | 2 | interface RouterCommon { 3 | on(eventName: 'started' | 'ended', handler?: (currentState: CurrentStateWithParams, previousState?: StateWithParams) => void): this 4 | addState(name: string, state: State): this 5 | } 6 | 7 | /* The router API while it's still in its builder phase */ 8 | interface Router extends RouterCommon { 9 | configure(options: ConfigOptions): this 10 | init(initState?: string, initParams?: Object): RouterAPI 11 | } 12 | 13 | /* The initialized router API */ 14 | interface RouterAPI extends RouterCommon { 15 | transitionTo(stateName: string, params?: Object, acc?: any): void 16 | transitionTo(pathQuery: string, acc?: any): void 17 | replaceParams(newParams: {[ key: string ]: any }): void 18 | backTo(stateName: string, defaultParams?: Object, acc?: any): void 19 | link(stateName: string, params?: Object): string 20 | previous(): StateWithParams | void 21 | current(): CurrentStateWithParams 22 | findState(optionsOrFullName: {}): State | void 23 | isFirstTransition(): boolean 24 | paramsDiff(): ParamsDiff 25 | } 26 | 27 | interface StateWithParams { 28 | uri: string 29 | params: Params 30 | name: string 31 | fullName: string 32 | data: Record 33 | 34 | isIn(fullName: string): boolean 35 | } 36 | 37 | interface ParamsDiff { 38 | update: Record 39 | enter: Record 40 | exit: Record 41 | all: Record 42 | } 43 | 44 | interface CurrentStateWithParams extends StateWithParams { 45 | paramsDiff: ParamsDiff 46 | } 47 | 48 | interface State { 49 | name: string 50 | fullName: string 51 | parent: State | void 52 | data: Record 53 | } 54 | 55 | interface ConfigOptions { 56 | enableLogs?: boolean 57 | interceptAnchors?: boolean 58 | notFound?: string 59 | urlSync?: 'history' | 'hash' 60 | hashPrefix?: string 61 | } 62 | 63 | type StateMap = Record 64 | 65 | type Params = Record 66 | 67 | type LifeCycleCallback = (params: Params, value: {}, router: RouterAPI) => void 68 | 69 | interface StateOptions { 70 | enter?: LifeCycleCallback 71 | exit?: LifeCycleCallback 72 | update?: LifeCycleCallback 73 | data?: Record 74 | } 75 | 76 | interface RouterObject { 77 | (states: StateMap): Router 78 | log: boolean 79 | } 80 | 81 | 82 | export const Router: RouterObject 83 | export function State(uri: string, options: StateOptions, children?: StateMap): State 84 | export var api: RouterAPI 85 | -------------------------------------------------------------------------------- /src/anchors.js: -------------------------------------------------------------------------------- 1 | 2 | let router 3 | 4 | function onMouseDown(evt) { 5 | const href = hrefForEvent(evt) 6 | 7 | if (href !== undefined) 8 | router.transitionTo(href) 9 | } 10 | 11 | function onMouseClick(evt) { 12 | const href = hrefForEvent(evt) 13 | 14 | if (href !== undefined) { 15 | evt.preventDefault() 16 | router.transitionTo(href) 17 | } 18 | } 19 | 20 | function hrefForEvent(evt) { 21 | if (evt.defaultPrevented || evt.metaKey || evt.ctrlKey || !isLeftButton(evt)) return 22 | 23 | const target = evt.target 24 | const anchor = anchorTarget(target) 25 | if (!anchor) return 26 | 27 | const dataNav = anchor.getAttribute('data-nav') 28 | 29 | if (dataNav == 'ignore') return 30 | if (evt.type == 'mousedown' && dataNav != 'mousedown') return 31 | 32 | let href = anchor.getAttribute('href') 33 | 34 | if (!href) return 35 | if (href.charAt(0) == '#') { 36 | if (router.options.urlSync != 'hash') return 37 | href = href.slice(1) 38 | } 39 | if (anchor.getAttribute('target') == '_blank') return 40 | if (!isLocalLink(anchor)) return 41 | 42 | // At this point, we have a valid href to follow. 43 | // Did the navigation already occur on mousedown though? 44 | if (evt.type == 'click' && dataNav == 'mousedown') { 45 | evt.preventDefault() 46 | return 47 | } 48 | 49 | return href 50 | } 51 | 52 | function isLeftButton(evt) { 53 | return evt.which == 1 54 | } 55 | 56 | function anchorTarget(target) { 57 | while (target) { 58 | if (target.nodeName == 'A') return target 59 | target = target.parentNode 60 | } 61 | } 62 | 63 | function isLocalLink(anchor) { 64 | let hostname = anchor.hostname 65 | let port = anchor.port 66 | let protocol = anchor.protocol 67 | 68 | // IE10 can lose the hostname/port property when setting a relative href from JS 69 | if (!hostname) { 70 | const tempAnchor = document.createElement("a") 71 | tempAnchor.href = anchor.href 72 | hostname = tempAnchor.hostname 73 | port = tempAnchor.port 74 | protocol = tempAnchor.protocol 75 | } 76 | 77 | const defaultPort = protocol.split(':')[0] === 'https' ? '443' : '80' 78 | 79 | const sameHostname = (hostname == location.hostname) 80 | const samePort = (port || defaultPort) == (location.port || defaultPort) 81 | 82 | return sameHostname && samePort 83 | } 84 | 85 | 86 | export default function interceptAnchors(forRouter) { 87 | router = forRouter 88 | 89 | document.addEventListener('mousedown', onMouseDown) 90 | document.addEventListener('click', onMouseClick) 91 | } 92 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | 2 | export function noop() {} 3 | 4 | export function arrayToObject(array) { 5 | return array.reduce((obj, item) => { 6 | obj[item] = 1 7 | return obj 8 | }, {}) 9 | } 10 | 11 | export function objectToArray(obj) { 12 | const array = [] 13 | for (let key in obj) array.push(obj[key]) 14 | return array 15 | } 16 | 17 | export function copyObject(obj) { 18 | const copy = {} 19 | for (let key in obj) copy[key] = obj[key] 20 | return copy 21 | } 22 | 23 | export function mergeObjects(to, from) { 24 | for (let key in from) to[key] = from[key] 25 | return to 26 | } 27 | 28 | export function mapValues(obj, fn) { 29 | const result = {} 30 | for (let key in obj) result[key] = fn(obj[key]) 31 | return result 32 | } 33 | 34 | /* 35 | * Return the set of all the keys that changed (either added, removed or modified). 36 | */ 37 | export function objectDiff(obj1, obj2) { 38 | const update = {} 39 | const enter = {} 40 | const exit = {} 41 | const all = {} 42 | 43 | obj1 = obj1 || {} 44 | 45 | for (let name in obj1) { 46 | if (!(name in obj2)) 47 | exit[name] = all[name] = true 48 | else if (obj1[name] != obj2[name]) 49 | update[name] = all[name] = true 50 | } 51 | 52 | for (let name in obj2) { 53 | if (!(name in obj1)) 54 | enter[name] = all[name] = true 55 | } 56 | 57 | return { all, update, enter, exit } 58 | } 59 | 60 | export function makeMessage() { 61 | let message = arguments[0] 62 | const tokens = Array.prototype.slice.call(arguments, 1) 63 | 64 | for (let i = 0, l = tokens.length; i < l; i++) 65 | message = message.replace('{' + i + '}', tokens[i]) 66 | 67 | return message 68 | } 69 | 70 | export function parsePaths(path) { 71 | return path.split('/') 72 | .filter(str => str.length) 73 | .map(str => decodeURIComponent(str)) 74 | } 75 | 76 | export function parseQueryParams(query) { 77 | return query ? query.split('&').reduce((res, paramValue) => { 78 | const [param, value] = paramValue.split('=') 79 | res[param] = decodeURIComponent(value) 80 | return res 81 | }, {}) : {} 82 | } 83 | 84 | 85 | var LEADING_SLASHES = /^\/+/ 86 | var TRAILING_SLASHES = /^([^?]*?)\/+$/ 87 | var TRAILING_SLASHES_BEFORE_QUERY = /\/+\?/ 88 | export function normalizePathQuery(pathQuery) { 89 | return ('/' + pathQuery 90 | .replace(LEADING_SLASHES, '') 91 | .replace(TRAILING_SLASHES, '$1') 92 | .replace(TRAILING_SLASHES_BEFORE_QUERY, '?')) 93 | } 94 | 95 | export function stateShorthand(uri, options, children) { 96 | return mergeObjects({ uri: uri, children: children || {} }, options) 97 | } 98 | -------------------------------------------------------------------------------- /src/Transition.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Create a new Transition instance. 3 | */ 4 | function Transition(fromStateWithParams, toStateWithParams, paramsDiff, acc, router, logger) { 5 | let root = { root: null, inclusive: true } 6 | let enters 7 | let exits 8 | 9 | const fromState = fromStateWithParams && fromStateWithParams.state 10 | const toState = toStateWithParams.state 11 | const params = toStateWithParams.params 12 | const isUpdate = (fromState == toState) 13 | 14 | const transition = { 15 | from: fromState, 16 | to: toState, 17 | toParams: params, 18 | cancel, 19 | run, 20 | cancelled: false, 21 | currentState: fromState 22 | } 23 | 24 | // The first transition has no fromState. 25 | if (fromState) 26 | root = transitionRoot(fromState, toState, isUpdate, paramsDiff) 27 | 28 | exits = fromState ? transitionStates(fromState, root) : [] 29 | enters = transitionStates(toState, root).reverse() 30 | 31 | function run() { 32 | startTransition(enters, exits, params, transition, isUpdate, acc, router, logger) 33 | } 34 | 35 | function cancel() { 36 | transition.cancelled = true 37 | } 38 | 39 | return transition 40 | } 41 | 42 | function startTransition(enters, exits, params, transition, isUpdate, acc, router, logger) { 43 | acc = acc || {} 44 | 45 | transition.exiting = true 46 | exits.forEach(state => { 47 | if (isUpdate && state.update) return 48 | runStep(state, 'exit', params, transition, acc, router, logger) 49 | }) 50 | transition.exiting = false 51 | 52 | enters.forEach(state => { 53 | const fn = (isUpdate && state.update) ? 'update' : 'enter' 54 | runStep(state, fn, params, transition, acc, router, logger) 55 | }) 56 | } 57 | 58 | function runStep(state, stepFn, params, transition, acc, router, logger) { 59 | if (transition.cancelled) return 60 | 61 | if (logger.enabled) { 62 | const capitalizedStep = stepFn[0].toUpperCase() + stepFn.slice(1) 63 | logger.log(capitalizedStep + ' ' + state.fullName) 64 | } 65 | 66 | const result = state[stepFn](params, acc, router) 67 | 68 | if (transition.cancelled) return 69 | 70 | transition.currentState = (stepFn == 'exit') ? state.parent : state 71 | 72 | return result 73 | } 74 | 75 | /* 76 | * The top-most fromState's parent that must be exited 77 | * or undefined if the two states are in distinct branches of the tree. 78 | */ 79 | function transitionRoot(fromState, toState, isUpdate, paramsDiff) { 80 | let closestCommonParent 81 | 82 | const parents = [fromState].concat(fromState.parents).reverse() 83 | 84 | // Find the closest common parent of the from/to states, if any. 85 | if (!isUpdate) { 86 | for (let i = 0; i < fromState.parents.length; i++) { 87 | const parent = fromState.parents[i] 88 | 89 | if (toState.parents.indexOf(parent) > -1) { 90 | closestCommonParent = parent 91 | break 92 | } 93 | } 94 | } 95 | 96 | // Find the top-most parent owning some updated param(s) or bail if we first reach the closestCommonParent 97 | for (let i = 0; i < parents.length; i++) { 98 | const parent = parents[i] 99 | 100 | for (let param in paramsDiff.all) { 101 | if (parent.params[param] || parent.queryParams[param]) 102 | return { root: parent, inclusive: true } 103 | } 104 | 105 | if (parent === closestCommonParent) 106 | return { root: closestCommonParent, inclusive: false } 107 | } 108 | 109 | return closestCommonParent 110 | ? { root: closestCommonParent, inclusive: false } 111 | : { inclusive: true } 112 | } 113 | 114 | function transitionStates(state, { root, inclusive }) { 115 | root = root || state.root 116 | 117 | const p = state.parents 118 | const end = Math.min(p.length, p.indexOf(root) + (inclusive ? 1 : 0)) 119 | 120 | return [state].concat(p.slice(0, end)) 121 | } 122 | 123 | 124 | export default Transition 125 | -------------------------------------------------------------------------------- /test/lib/qunit.css: -------------------------------------------------------------------------------- 1 | /** 2 | * QUnit v1.9.0 - A JavaScript Unit Testing Framework 3 | * 4 | * http://docs.jquery.com/QUnit 5 | * 6 | * Copyright (c) 2012 John Resig, Jörn Zaefferer 7 | * Dual licensed under the MIT (MIT-LICENSE.txt) 8 | * or GPL (GPL-LICENSE.txt) licenses. 9 | */ 10 | 11 | /** Font Family and Sizes */ 12 | 13 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult { 14 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif; 15 | } 16 | 17 | #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; } 18 | #qunit-tests { font-size: smaller; } 19 | 20 | 21 | /** Resets */ 22 | 23 | #qunit-tests, #qunit-tests ol, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult { 24 | margin: 0; 25 | padding: 0; 26 | } 27 | 28 | 29 | /** Header */ 30 | 31 | #qunit-header { 32 | padding: 0.5em 0 0.5em 1em; 33 | 34 | color: #8699a4; 35 | background-color: #0d3349; 36 | 37 | font-size: 1.5em; 38 | line-height: 1em; 39 | font-weight: normal; 40 | 41 | border-radius: 5px 5px 0 0; 42 | -moz-border-radius: 5px 5px 0 0; 43 | -webkit-border-top-right-radius: 5px; 44 | -webkit-border-top-left-radius: 5px; 45 | } 46 | 47 | #qunit-header a { 48 | text-decoration: none; 49 | color: #c2ccd1; 50 | } 51 | 52 | #qunit-header a:hover, 53 | #qunit-header a:focus { 54 | color: #fff; 55 | } 56 | 57 | #qunit-testrunner-toolbar label { 58 | display: inline-block; 59 | padding: 0 .5em 0 .1em; 60 | } 61 | 62 | #qunit-banner { 63 | height: 5px; 64 | } 65 | 66 | #qunit-testrunner-toolbar { 67 | padding: 0.5em 0 0.5em 2em; 68 | color: #5E740B; 69 | background-color: #eee; 70 | } 71 | 72 | #qunit-userAgent { 73 | padding: 0.5em 0 0.5em 2.5em; 74 | background-color: #2b81af; 75 | color: #fff; 76 | text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px; 77 | } 78 | 79 | 80 | /** Tests: Pass/Fail */ 81 | 82 | #qunit-tests { 83 | list-style-position: inside; 84 | } 85 | 86 | #qunit-tests li { 87 | padding: 0.4em 0.5em 0.4em 2.5em; 88 | border-bottom: 1px solid #fff; 89 | list-style-position: inside; 90 | } 91 | 92 | #qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running { 93 | display: none; 94 | } 95 | 96 | #qunit-tests li strong { 97 | cursor: pointer; 98 | } 99 | 100 | #qunit-tests li a { 101 | padding: 0.5em; 102 | color: #c2ccd1; 103 | text-decoration: none; 104 | } 105 | #qunit-tests li a:hover, 106 | #qunit-tests li a:focus { 107 | color: #000; 108 | } 109 | 110 | #qunit-tests ol { 111 | margin-top: 0.5em; 112 | padding: 0.5em; 113 | 114 | background-color: #fff; 115 | 116 | border-radius: 5px; 117 | -moz-border-radius: 5px; 118 | -webkit-border-radius: 5px; 119 | } 120 | 121 | #qunit-tests table { 122 | border-collapse: collapse; 123 | margin-top: .2em; 124 | } 125 | 126 | #qunit-tests th { 127 | text-align: right; 128 | vertical-align: top; 129 | padding: 0 .5em 0 0; 130 | } 131 | 132 | #qunit-tests td { 133 | vertical-align: top; 134 | } 135 | 136 | #qunit-tests pre { 137 | margin: 0; 138 | white-space: pre-wrap; 139 | word-wrap: break-word; 140 | } 141 | 142 | #qunit-tests del { 143 | background-color: #e0f2be; 144 | color: #374e0c; 145 | text-decoration: none; 146 | } 147 | 148 | #qunit-tests ins { 149 | background-color: #ffcaca; 150 | color: #500; 151 | text-decoration: none; 152 | } 153 | 154 | /*** Test Counts */ 155 | 156 | #qunit-tests b.counts { color: black; } 157 | #qunit-tests b.passed { color: #5E740B; } 158 | #qunit-tests b.failed { color: #710909; } 159 | 160 | #qunit-tests li li { 161 | padding: 5px; 162 | background-color: #fff; 163 | border-bottom: none; 164 | list-style-position: inside; 165 | } 166 | 167 | /*** Passing Styles */ 168 | 169 | #qunit-tests li li.pass { 170 | color: #3c510c; 171 | background-color: #fff; 172 | border-left: 10px solid #C6E746; 173 | } 174 | 175 | #qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; } 176 | #qunit-tests .pass .test-name { color: #366097; } 177 | 178 | #qunit-tests .pass .test-actual, 179 | #qunit-tests .pass .test-expected { color: #999999; } 180 | 181 | #qunit-banner.qunit-pass { background-color: #C6E746; } 182 | 183 | /*** Failing Styles */ 184 | 185 | #qunit-tests li li.fail { 186 | color: #710909; 187 | background-color: #fff; 188 | border-left: 10px solid #EE5757; 189 | white-space: pre; 190 | } 191 | 192 | #qunit-tests > li:last-child { 193 | border-radius: 0 0 5px 5px; 194 | -moz-border-radius: 0 0 5px 5px; 195 | -webkit-border-bottom-right-radius: 5px; 196 | -webkit-border-bottom-left-radius: 5px; 197 | } 198 | 199 | #qunit-tests .fail { color: #000000; background-color: #EE5757; } 200 | #qunit-tests .fail .test-name, 201 | #qunit-tests .fail .module-name { color: #000000; } 202 | 203 | #qunit-tests .fail .test-actual { color: #EE5757; } 204 | #qunit-tests .fail .test-expected { color: green; } 205 | 206 | #qunit-banner.qunit-fail { background-color: #EE5757; } 207 | 208 | 209 | /** Result */ 210 | 211 | #qunit-testresult { 212 | padding: 0.5em 0.5em 0.5em 2.5em; 213 | 214 | color: #2b81af; 215 | background-color: #D2E0E6; 216 | 217 | border-bottom: 1px solid white; 218 | } 219 | #qunit-testresult .module-name { 220 | font-weight: bold; 221 | } 222 | 223 | /** Fixture */ 224 | 225 | #qunit-fixture { 226 | position: absolute; 227 | top: -10000px; 228 | left: -10000px; 229 | width: 1000px; 230 | height: 1000px; 231 | } 232 | -------------------------------------------------------------------------------- /src/State.js: -------------------------------------------------------------------------------- 1 | import * as util from './util' 2 | 3 | const PARAMS = /:[^\\?\/]*/g 4 | 5 | /* 6 | * Creates a new State instance from a {uri, enter, exit, update, children} object. 7 | * This is the internal representation of a state used by the router. 8 | */ 9 | function State(options) { 10 | const state = { options } 11 | const states = options.children 12 | 13 | state.path = pathFromURI(options.uri) 14 | state.params = paramsFromURI(options.uri) 15 | state.queryParams = queryParamsFromURI(options.uri) 16 | state.states = states 17 | state.data = options.data 18 | 19 | state.enter = options.enter || util.noop 20 | state.update = options.update 21 | state.exit = options.exit || util.noop 22 | 23 | /* 24 | * Initialize and freeze this state. 25 | */ 26 | function init(router, name, parent) { 27 | state.router = router 28 | state.name = name 29 | state.isDefault = name == '_default_' 30 | state.parent = parent 31 | state.parents = getParents() 32 | state.root = state.parent ? state.parents[state.parents.length - 1] : state 33 | state.children = util.objectToArray(states) 34 | state.fullName = getFullName() 35 | state.asPublic = makePublicAPI() 36 | 37 | eachChildState((name, childState) => { 38 | childState.init(router, name, state) 39 | }) 40 | } 41 | 42 | /* 43 | * The full path, composed of all the individual paths of this state and its parents. 44 | */ 45 | function fullPath() { 46 | let result = state.path 47 | let stateParent = state.parent 48 | 49 | while (stateParent) { 50 | if (stateParent.path) result = stateParent.path + '/' + result 51 | stateParent = stateParent.parent 52 | } 53 | 54 | return result 55 | } 56 | 57 | /* 58 | * The list of all parents, starting from the closest ones. 59 | */ 60 | function getParents() { 61 | const parents = [] 62 | let parent = state.parent 63 | 64 | while (parent) { 65 | parents.push(parent) 66 | parent = parent.parent 67 | } 68 | 69 | return parents 70 | } 71 | 72 | /* 73 | * The fully qualified name of this state. 74 | * e.g granparentName.parentName.name 75 | */ 76 | function getFullName() { 77 | const result = state.parents.reduceRight((acc, parent) => { 78 | return acc + parent.name + '.' 79 | }, '') + state.name 80 | 81 | return state.isDefault 82 | ? result.replace('._default_', '') 83 | : result 84 | } 85 | 86 | function allQueryParams() { 87 | return state.parents.reduce((acc, parent) => { 88 | return util.mergeObjects(acc, parent.queryParams) 89 | }, util.copyObject(state.queryParams)) 90 | } 91 | 92 | function makePublicAPI() { 93 | return { 94 | name: state.name, 95 | fullName: state.fullName, 96 | data: options.data || {}, 97 | parent: state.parent && state.parent.asPublic 98 | } 99 | } 100 | 101 | function eachChildState(callback) { 102 | for (let name in states) callback(name, states[name]) 103 | } 104 | 105 | /* 106 | * Returns whether this state matches the passed path Array. 107 | * In case of a match, the actual param values are returned. 108 | */ 109 | function matches(paths) { 110 | const params = {} 111 | const nonRestStatePaths = state.paths.filter(p => p[p.length - 1] !== '*') 112 | 113 | /* This state has more paths than the passed paths, it cannot be a match */ 114 | if (nonRestStatePaths.length > paths.length) return false 115 | 116 | /* Checks if the paths match one by one */ 117 | for (let i = 0; i < paths.length; i++) { 118 | const path = paths[i] 119 | const thatPath = state.paths[i] 120 | 121 | /* This state has less paths than the passed paths, it cannot be a match */ 122 | if (!thatPath) return false 123 | 124 | const isRest = thatPath[thatPath.length - 1] === '*' 125 | if (isRest) { 126 | const name = paramName(thatPath) 127 | params[name] = paths.slice(i).join('/') 128 | return params 129 | } 130 | 131 | const isDynamic = thatPath[0] === ':' 132 | if (isDynamic) { 133 | const name = paramName(thatPath) 134 | params[name] = path 135 | } 136 | else if (thatPath != path) return false 137 | } 138 | 139 | return params 140 | } 141 | 142 | /* 143 | * Returns a URI built from this state and the passed params. 144 | */ 145 | function interpolate(params) { 146 | const path = state.fullPath().replace(PARAMS, p => params[paramName(p)] || '') 147 | 148 | const queryParams = allQueryParams() 149 | const passedQueryParams = Object.keys(params).filter(p => queryParams[p]) 150 | 151 | const query = passedQueryParams.map(p => p + '=' + params[p]).join('&') 152 | 153 | return path + (query.length ? ('?' + query) : '') 154 | } 155 | 156 | 157 | function toString() { 158 | return state.fullName 159 | } 160 | 161 | 162 | state.init = init 163 | state.fullPath = fullPath 164 | state.allQueryParams = allQueryParams 165 | state.matches = matches 166 | state.interpolate = interpolate 167 | state.toString = toString 168 | 169 | return state 170 | } 171 | 172 | function paramName(param) { 173 | return param[param.length - 1] === '*' 174 | ? param.substr(1).slice(0, -1) 175 | : param.substr(1) 176 | } 177 | 178 | function pathFromURI(uri) { 179 | return (uri || '').split('?')[0] 180 | } 181 | 182 | function paramsFromURI(uri) { 183 | const matches = PARAMS.exec(uri) 184 | return matches ? util.arrayToObject(matches.map(paramName)) : {} 185 | } 186 | 187 | function queryParamsFromURI(uri) { 188 | const query = (uri || '').split('?')[1] 189 | return query ? util.arrayToObject(query.split('&')): {} 190 | } 191 | 192 | 193 | export default State 194 | -------------------------------------------------------------------------------- /test/integration-tests.js: -------------------------------------------------------------------------------- 1 | 2 | // To run these tests, setup a http server's root as abyssa's repository root 3 | 4 | Router = Abyssa.Router 5 | State = Abyssa.State 6 | 7 | //Router.enableLogs() 8 | 9 | var initialURL = window.location.href 10 | var testElements = document.getElementById('test-elements') 11 | var router 12 | 13 | QUnit.testDone(function() { 14 | changeURL(initialURL) 15 | testElements.innerHTML = '' 16 | router.terminate() 17 | }) 18 | 19 | 20 | asyncTest('history.back() on inital redirect state', function() { 21 | // This test has to be first because it requires an empty hitory 22 | equal(history.length, 1) 23 | 24 | router = Router({ 25 | 26 | index: State('test', { 27 | children: { 28 | 'integrationTests.html': State('integrationTests.html', { 29 | enter: function() { router.transitionTo('/cart') } 30 | }), 31 | } 32 | }), 33 | articles: State('articles'), 34 | books: State('books', { 35 | enter: function() { router.transitionTo('/articles') } 36 | }), 37 | cart: State('cart'), 38 | 39 | }) 40 | 41 | router.init() 42 | equal(history.length, 1) 43 | 44 | router.transitionTo('/cart') 45 | equal(router.urlPathQuery(), '/cart') 46 | equal(history.length, 1) 47 | 48 | history.back() 49 | Q.delay(60).then(function() { 50 | equal(router.urlPathQuery(), '/cart') 51 | equal(history.length, 1) 52 | }) 53 | .then(function(){ 54 | router.transitionTo('/books') 55 | equal(router.urlPathQuery(), '/articles') 56 | equal(history.length, 2) 57 | }) 58 | .then(startLater) 59 | 60 | }) 61 | 62 | 63 | asyncTest('Router initialization from initial URL', function() { 64 | 65 | changeURL('/initialState/36') 66 | 67 | router = Router({ 68 | 69 | index: State('initialState/:num', { enter: function(param) { 70 | strictEqual(param.num, '36') 71 | startLater() 72 | }}) 73 | 74 | }).init() 75 | 76 | }) 77 | 78 | 79 | asyncTest('Default anchor interception', function() { 80 | var a = document.createElement('a') 81 | a.href = '/articles/33' 82 | testElements.appendChild(a) 83 | 84 | router = Router({ 85 | 86 | index: State(''), 87 | 88 | articles: State('articles/:id', { enter: function(params) { 89 | strictEqual(params.id, '33') 90 | startLater() 91 | }}) 92 | 93 | }).init('') 94 | 95 | simulateClick(a) 96 | }) 97 | 98 | 99 | asyncTest('Mousedown anchor interception', function() { 100 | var a = document.createElement('a') 101 | a.href = '/articles/33' 102 | a.setAttribute('data-nav', 'mousedown') 103 | testElements.appendChild(a) 104 | 105 | router = Router({ 106 | 107 | index: State(''), 108 | 109 | articles: State('articles/:id', { enter: function(params) { 110 | strictEqual(params.id, '33') 111 | startLater() 112 | }}) 113 | 114 | }).init('') 115 | 116 | simulateMousedown(a) 117 | }) 118 | 119 | 120 | asyncTest('Redirect', function() { 121 | 122 | router = Router({ 123 | 124 | index: State('index', { enter: function() { 125 | router.transitionTo('articles') 126 | }}), 127 | 128 | articles: State('articles') 129 | 130 | }) 131 | 132 | router.init('index') 133 | 134 | equal(router.urlPathQuery(), '/articles') 135 | startLater() 136 | }) 137 | 138 | 139 | asyncTest('history.back()', function() { 140 | 141 | router = Router({ 142 | 143 | index: State('index'), 144 | books: State('books'), 145 | articles: State('articles') 146 | 147 | }).init('index') 148 | 149 | 150 | router.transitionTo('articles') 151 | router.transitionTo('books') 152 | equal(router.urlPathQuery(), '/books') 153 | history.back() 154 | 155 | Q.delay(60).then(function() { 156 | equal(router.urlPathQuery(), '/articles') 157 | }) 158 | .then(startLater) 159 | 160 | }) 161 | 162 | 163 | asyncTest('history.back() on the notFound state', function() { 164 | 165 | router = Router({ 166 | index: State('index'), 167 | notFound: State('notFound') 168 | }) 169 | .configure({ 170 | notFound: 'notFound' 171 | }) 172 | .init('index') 173 | 174 | 175 | router.transitionTo('/wat') 176 | equal(router.current().name, 'notFound') 177 | router.transitionTo('index') 178 | history.back() 179 | 180 | Q.delay(60).then(function() { 181 | // TODO: This assertion should pass ideally uncomment after the biggest refactorings 182 | equal(router.current().name, 'notFound') 183 | }) 184 | .then(startLater) 185 | 186 | }) 187 | 188 | asyncTest('history.back() when chained redirection', function() { 189 | 190 | var api = Abyssa.api 191 | 192 | const pageRedirect = { enter: function() { api.transitionTo('pageRedirectToPage1') }} 193 | const pageRedirectToPage1 = { enter: function() { api.transitionTo('page1') }} 194 | 195 | router = Router({ 196 | index: State('/test/integrationTests.html'), 197 | page1: State('page1'), 198 | pageRedirect: State('pageRedirect', pageRedirect), 199 | pageRedirectToPage1: State('pageRedirectToPage1', pageRedirectToPage1) 200 | }).init('index') //in the bug case the inialisation is init() 201 | 202 | router.transitionTo('pageRedirect') 203 | equal(router.current().name, 'page1') 204 | history.back() 205 | 206 | Q.delay(60).then(function() { 207 | equal(router.current().name, 'index') 208 | }) 209 | .then(startLater) 210 | 211 | }) 212 | 213 | asyncTest('hash mode switched on', function() { 214 | 215 | var lastParams 216 | 217 | window.addEventListener('hashchange', startTest) 218 | window.location.hash = '/category1/56' 219 | 220 | function startTest() { 221 | window.removeEventListener('hashchange', startTest) 222 | 223 | router = Router({ 224 | 225 | index: State(''), 226 | 227 | category1: State('category1', {}, { 228 | detail: State(':id', { enter: function(params) { 229 | lastParams = params 230 | }}) 231 | }) 232 | 233 | }) 234 | .configure({ 235 | urlSync: 'hash' 236 | }) 237 | .init() 238 | 239 | stateShouldBeCategoryDetail() 240 | goToIndex() 241 | stateShouldBeIndex() 242 | goToCategoryDetail() 243 | stateShouldBeCategoryDetail2() 244 | startLater() 245 | 246 | function stateShouldBeCategoryDetail() { 247 | strictEqual(router.current().fullName, 'category1.detail') 248 | strictEqual(lastParams.id, '56') 249 | strictEqual(window.location.hash, '#/category1/56') 250 | } 251 | 252 | function goToIndex() { 253 | router.transitionTo('/') 254 | } 255 | 256 | function stateShouldBeIndex() { 257 | strictEqual(router.current().fullName, 'index') 258 | strictEqual(window.location.hash, '#/') 259 | } 260 | 261 | function goToCategoryDetail() { 262 | router.transitionTo('category1.detail', { id: 88 }) 263 | } 264 | 265 | function stateShouldBeCategoryDetail2() { 266 | strictEqual(router.current().fullName, 'category1.detail') 267 | strictEqual(lastParams.id, '88') 268 | strictEqual(window.location.hash, '#/category1/88') 269 | } 270 | } 271 | 272 | }) 273 | 274 | 275 | asyncTest('customize hashbang', function() { 276 | 277 | var lastParams 278 | 279 | window.addEventListener('hashchange', startTest) 280 | window.location.hash = '!/category1/56' 281 | 282 | function startTest() { 283 | window.removeEventListener('hashchange', startTest) 284 | 285 | router = Router({ 286 | 287 | index: State(''), 288 | 289 | category1: State('category1', {}, { 290 | detail: State(':id', { enter: function(params) { 291 | lastParams = params 292 | }}) 293 | }) 294 | 295 | }) 296 | .configure({ 297 | urlSync: 'hash', 298 | hashPrefix: '!' 299 | }) 300 | .init() 301 | 302 | stateShouldBeCategoryDetail() 303 | goToIndex() 304 | stateShouldBeIndex() 305 | goToCategoryDetail() 306 | stateShouldBeCategoryDetail2() 307 | startLater() 308 | 309 | function stateShouldBeCategoryDetail() { 310 | strictEqual(router.current().fullName, 'category1.detail') 311 | strictEqual(lastParams.id, '56') 312 | strictEqual(window.location.hash, '#!/category1/56') 313 | } 314 | 315 | function goToIndex() { 316 | router.transitionTo('/') 317 | } 318 | 319 | function stateShouldBeIndex() { 320 | strictEqual(router.current().fullName, 'index') 321 | strictEqual(window.location.hash, '#!/') 322 | } 323 | 324 | function goToCategoryDetail() { 325 | router.transitionTo('category1.detail', { id: 88 }) 326 | } 327 | 328 | function stateShouldBeCategoryDetail2() { 329 | strictEqual(router.current().fullName, 'category1.detail') 330 | strictEqual(lastParams.id, '88') 331 | strictEqual(window.location.hash, '#!/category1/88') 332 | } 333 | } 334 | 335 | }) 336 | 337 | test('replaceParams', function() { 338 | var router = Abyssa.api 339 | 340 | Router({ 341 | 342 | articles: { 343 | uri: 'articles?popup', 344 | 345 | children: { 346 | detail: { 347 | uri: ':id', 348 | 349 | children: { 350 | moreDetails: { 351 | uri: 'moreDetails' 352 | } 353 | } 354 | } 355 | } 356 | } 357 | 358 | }) 359 | .init('articles/33/moreDetails?popup=true') 360 | 361 | equal(router.current().params.id, '33') 362 | equal(router.current().params.popup, 'true') 363 | 364 | var transitionHappened = false 365 | router.on('ended', function() { 366 | transitionHappened = true 367 | router.on('ended', undefined) 368 | }) 369 | 370 | router.replaceParams({ id: '44', popup: 'false' }) 371 | 372 | equal(transitionHappened, false) 373 | equal(router.current().params.id, '44') 374 | equal(router.current().params.popup, 'false') 375 | equal(router.current().uri, '/articles/44/moreDetails?popup=false') 376 | equal(router.urlPathQuery(), '/articles/44/moreDetails?popup=false') 377 | 378 | router.replaceParams({ id: '44', popup: undefined }) 379 | 380 | equal(transitionHappened, false) 381 | equal(router.current().params.id, '44') 382 | equal(router.current().params.popup, undefined) 383 | equal(router.current().uri, '/articles/44/moreDetails') 384 | equal(router.urlPathQuery(), '/articles/44/moreDetails') 385 | }) 386 | 387 | 388 | function changeURL(pathQuery) { 389 | history.pushState('', '', pathQuery) 390 | } 391 | 392 | function simulateClick(element) { 393 | var event = document.createEvent('MouseEvents') 394 | event.initMouseEvent('click', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null) 395 | element.dispatchEvent(event) 396 | } 397 | 398 | function simulateMousedown(element) { 399 | var event = document.createEvent('MouseEvents') 400 | event.initMouseEvent('mousedown', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null) 401 | element.dispatchEvent(event) 402 | } 403 | 404 | // The hashchange event is dispatched asynchronously. 405 | // At the end of a test changing the hash, give the event enough time to be dispatched 406 | // so that the following test's router doesn't try to react to it. 407 | function startLater() { 408 | Q.delay(80).then(start) 409 | } 410 | -------------------------------------------------------------------------------- /src/Router.js: -------------------------------------------------------------------------------- 1 | import interceptAnchors from './anchors' 2 | import StateWithParams from './StateWithParams' 3 | import Transition from './Transition' 4 | import * as util from './util' 5 | import State from './State' 6 | import api from './api' 7 | 8 | 9 | const defaultOptions = { 10 | enableLogs: false, 11 | interceptAnchors: true, 12 | notFound: null, 13 | urlSync: 'history', 14 | hashPrefix: '' 15 | } 16 | 17 | /* 18 | * Create a new Router instance, passing any state defined declaratively. 19 | * More states can be added using addState(). 20 | * 21 | * Because a router manages global state (the URL), only one instance of Router 22 | * should be used inside an application. 23 | */ 24 | function Router(declarativeStates) { 25 | const router = {} 26 | const states = stateTrees(declarativeStates) 27 | const eventCallbacks = {} 28 | 29 | let options = util.copyObject(defaultOptions) 30 | let firstTransition = true 31 | let ignoreNextURLChange = false 32 | let currentPathQuery 33 | let currentParamsDiff = {} 34 | let currentState 35 | let previousState 36 | let transition 37 | let leafStates 38 | let urlChanged 39 | let initialized 40 | let hashSlashString 41 | 42 | /* 43 | * Setting a new state will start a transition from the current state to the target state. 44 | * A successful transition will result in the URL being changed. 45 | * A failed transition will leave the router in its current state. 46 | */ 47 | function setState(state, params, acc) { 48 | const fromState = transition 49 | ? StateWithParams(transition.currentState, transition.toParams) 50 | : currentState 51 | 52 | const diff = util.objectDiff(fromState && fromState.params, params) 53 | 54 | const toState = StateWithParams(state, params, currentPathQuery, diff) 55 | 56 | if (preventTransition(fromState, toState, diff)) { 57 | if (transition && transition.exiting) cancelTransition() 58 | return 59 | } 60 | 61 | if (transition) cancelTransition() 62 | 63 | // While the transition is running, any code asking the router about the previous/current state should 64 | // get the end result state. 65 | previousState = currentState 66 | currentState = toState 67 | currentParamsDiff = diff 68 | 69 | transition = Transition( 70 | fromState, 71 | toState, 72 | diff, 73 | acc, 74 | router, 75 | logger 76 | ) 77 | 78 | startingTransition(fromState, toState) 79 | 80 | // In case of a redirect() called from 'startingTransition', the transition already ended. 81 | if (transition) transition.run() 82 | 83 | // In case of a redirect() called from the transition itself, the transition already ended 84 | if (transition) { 85 | if (transition.cancelled) currentState = fromState 86 | else endingTransition(fromState, toState) 87 | } 88 | 89 | transition = null 90 | } 91 | 92 | function cancelTransition() { 93 | logger.log('Cancelling existing transition from {0} to {1}', 94 | transition.from, transition.to) 95 | 96 | transition.cancel() 97 | 98 | firstTransition = false 99 | } 100 | 101 | function startingTransition(fromState, toState) { 102 | logger.log('Starting transition from {0} to {1}', fromState, toState) 103 | 104 | const from = fromState ? fromState.asPublic : null 105 | const to = toState.asPublic 106 | 107 | eventCallbacks.started && eventCallbacks.started(to, from) 108 | } 109 | 110 | function endingTransition(fromState, toState) { 111 | if (!urlChanged && !firstTransition) { 112 | logger.log('Updating URL: {0}', currentPathQuery) 113 | updateURLFromState(currentPathQuery, document.title, currentPathQuery) 114 | } 115 | 116 | firstTransition = false 117 | 118 | logger.log('Transition from {0} to {1} ended', fromState, toState) 119 | 120 | toState.state.lastParams = toState.params 121 | 122 | const from = fromState ? fromState.asPublic : null 123 | const to = toState.asPublic 124 | eventCallbacks.ended && eventCallbacks.ended(to, from) 125 | } 126 | 127 | function updateURLFromState(state, title, url) { 128 | if (isHashMode()) { 129 | ignoreNextURLChange = true 130 | location.hash = options.hashPrefix + url 131 | } 132 | else if(!initialized) { 133 | history.replaceState(state, title, url) 134 | } 135 | else 136 | history.pushState(state, title, url) 137 | } 138 | 139 | /* 140 | * Return whether the passed state is the same as the current one 141 | * in which case the router can ignore the change. 142 | */ 143 | function preventTransition(current, newState, diff) { 144 | if (!current) return false 145 | 146 | return (newState.state == current.state) && (Object.keys(diff.all).length == 0) 147 | } 148 | 149 | /* 150 | * The state wasn't found 151 | * Transition to the 'notFound' state if the developer specified it or else throw an error. 152 | */ 153 | function notFound(state) { 154 | logger.log('State not found: {0}', state) 155 | 156 | if (options.notFound) 157 | return setState(leafStates[options.notFound], {}) 158 | else throw new Error ('State "' + state + '" could not be found') 159 | } 160 | 161 | /* 162 | * Configure the router before its initialization. 163 | * The available options are: 164 | * enableLogs: Whether (debug and error) console logs should be enabled. Defaults to false. 165 | * interceptAnchors: Whether anchor mousedown/clicks should be intercepted and trigger a state change. Defaults to true. 166 | * notFound: The State to enter when no state matching the current path query or name could be found. Defaults to null. 167 | * urlSync: How should the router maintain the current state and the url in sync. Defaults to true (history API). 168 | * hashPrefix: Customize the hash separator. Set to '!' in order to have a hashbang like '/#!/'. Defaults to empty string. 169 | */ 170 | function configure(withOptions) { 171 | util.mergeObjects(options, withOptions) 172 | return router 173 | } 174 | 175 | /* 176 | * Initialize the router. 177 | * The router will immediately initiate a transition to, in order of priority: 178 | * 1) The init state passed as an argument 179 | * 2) The state captured by the current URL 180 | */ 181 | function init(initState, initParams) { 182 | if (options.enableLogs || Router.log) 183 | Router.enableLogs() 184 | 185 | if (options.interceptAnchors) 186 | interceptAnchors(router) 187 | 188 | hashSlashString = '#' + options.hashPrefix + '/' 189 | 190 | logger.log('Router init') 191 | 192 | initStates() 193 | logStateTree() 194 | 195 | initState = (initState !== undefined) ? initState : urlPathQuery() 196 | 197 | logger.log('Initializing to state {0}', initState || '""') 198 | transitionTo(initState, initParams) 199 | 200 | listenToURLChanges() 201 | 202 | initialized = true 203 | return router 204 | } 205 | 206 | /* 207 | * Remove any possibility of side effect this router instance might cause. 208 | * Used for testing purposes where we keep reusing the same router instance. 209 | */ 210 | function terminate() { 211 | window.onhashchange = null 212 | window.onpopstate = null 213 | options = util.copyObject(defaultOptions) 214 | logger.enabled = false 215 | logger.log = logger.error = util.noop 216 | } 217 | 218 | function listenToURLChanges() { 219 | 220 | function onURLChange(evt) { 221 | if (ignoreNextURLChange) { 222 | ignoreNextURLChange = false 223 | return 224 | } 225 | 226 | const newState = evt.state || urlPathQuery() 227 | 228 | logger.log('URL changed: {0}', newState) 229 | urlChanged = true 230 | setStateForPathQuery(newState) 231 | } 232 | 233 | window[isHashMode() ? 'onhashchange' : 'onpopstate'] = onURLChange 234 | } 235 | 236 | function initStates() { 237 | const stateArray = util.objectToArray(states) 238 | 239 | addDefaultStates(stateArray) 240 | 241 | eachRootState((name, state) => { 242 | state.init(router, name) 243 | }) 244 | 245 | assertPathUniqueness(stateArray) 246 | 247 | leafStates = registerLeafStates(stateArray, {}) 248 | 249 | assertNoAmbiguousPaths() 250 | } 251 | 252 | function assertPathUniqueness(states) { 253 | const paths = {} 254 | 255 | states.forEach(state => { 256 | if (paths[state.path]) { 257 | const fullPaths = states.map(s => s.fullPath() || 'empty') 258 | throw new Error('Two sibling states have the same path (' + fullPaths + ')') 259 | } 260 | 261 | paths[state.path] = 1 262 | assertPathUniqueness(state.children) 263 | }) 264 | } 265 | 266 | function assertNoAmbiguousPaths() { 267 | const paths = {} 268 | 269 | for (var name in leafStates) { 270 | const path = util.normalizePathQuery(leafStates[name].fullPath()) 271 | if (paths[path]) throw new Error('Ambiguous state paths: ' + path) 272 | paths[path] = 1 273 | } 274 | } 275 | 276 | function addDefaultStates(states) { 277 | states.forEach(state => { 278 | var children = util.objectToArray(state.states) 279 | 280 | // This is a parent state: Add a default state to it if there isn't already one 281 | if (children.length) { 282 | addDefaultStates(children) 283 | 284 | var hasDefaultState = children.reduce((result, state) => { 285 | return state.path == '' || result 286 | }, false) 287 | 288 | if (hasDefaultState) return 289 | 290 | var defaultState = State({ uri: '' }) 291 | state.states._default_ = defaultState 292 | } 293 | }) 294 | } 295 | 296 | function eachRootState(callback) { 297 | for (let name in states) callback(name, states[name]) 298 | } 299 | 300 | function registerLeafStates(states, leafStates) { 301 | return states.reduce((leafStates, state) => { 302 | if (state.children.length) 303 | return registerLeafStates(state.children, leafStates) 304 | else { 305 | leafStates[state.fullName] = state 306 | state.paths = util.parsePaths(state.fullPath()) 307 | return leafStates 308 | } 309 | }, leafStates) 310 | } 311 | 312 | /* 313 | * Request a programmatic state change. 314 | * 315 | * Two notations are supported: 316 | * transitionTo('my.target.state', {id: 33, filter: 'desc'}) 317 | * transitionTo('target/33?filter=desc') 318 | */ 319 | function transitionTo(pathQueryOrName) { 320 | const name = leafStates[pathQueryOrName] 321 | const params = (name ? arguments[1] : null) || {} 322 | const acc = name ? arguments[2] : arguments[1] 323 | 324 | logger.log('Changing state to {0}', pathQueryOrName || '""') 325 | 326 | urlChanged = false 327 | 328 | if (name) 329 | setStateByName(name, params, acc) 330 | else 331 | setStateForPathQuery(pathQueryOrName, acc) 332 | } 333 | 334 | /* 335 | * Replaces the current state's params in the history with new params. 336 | * The state is NOT exited/re-entered. 337 | */ 338 | function replaceParams(newParams) { 339 | if (!currentState) return 340 | 341 | const newUri = router.link(currentState.state.fullName, newParams) 342 | 343 | currentState = StateWithParams(currentState.state, newParams, newUri) 344 | 345 | history.replaceState(newUri, document.title, newUri) 346 | } 347 | 348 | /* 349 | * Attempt to navigate to 'stateName' with its previous params or 350 | * fallback to the defaultParams parameter if the state was never entered. 351 | */ 352 | function backTo(stateName, defaultParams, acc) { 353 | const params = leafStates[stateName].lastParams || defaultParams 354 | transitionTo(stateName, params, acc) 355 | } 356 | 357 | function setStateForPathQuery(pathQuery, acc) { 358 | let state, params, _state, _params 359 | 360 | currentPathQuery = util.normalizePathQuery(pathQuery) 361 | 362 | const pq = currentPathQuery.split('?') 363 | const path = pq[0] 364 | const query = pq[1] 365 | const paths = util.parsePaths(path) 366 | const queryParams = util.parseQueryParams(query) 367 | 368 | for (var name in leafStates) { 369 | _state = leafStates[name] 370 | _params = _state.matches(paths) 371 | 372 | if (_params) { 373 | state = _state 374 | params = util.mergeObjects(_params, queryParams) 375 | break 376 | } 377 | } 378 | 379 | if (state) setState(state, params, acc) 380 | else notFound(currentPathQuery) 381 | } 382 | 383 | function setStateByName(name, params, acc) { 384 | const state = leafStates[name] 385 | 386 | if (!state) return notFound(name) 387 | 388 | const pathQuery = interpolate(state, params) 389 | setStateForPathQuery(pathQuery, acc) 390 | } 391 | 392 | /* 393 | * Add a new root state to the router. 394 | * The name must be unique among root states. 395 | */ 396 | function addState(name, state) { 397 | if (states[name]) 398 | throw new Error('A state already exist in the router with the name ' + name) 399 | 400 | state = stateTree(state) 401 | 402 | states[name] = state 403 | 404 | // The router is already initialized: Hot patch this state in. 405 | if (initialized) { 406 | state.init(router, name) 407 | registerLeafStates([state], leafStates) 408 | } 409 | 410 | return router 411 | } 412 | 413 | /* 414 | * Read the path/query from the URL. 415 | */ 416 | function urlPathQuery() { 417 | const hashSlash = location.href.indexOf(hashSlashString) 418 | let pathQuery 419 | 420 | if (hashSlash > -1) 421 | pathQuery = location.href.slice(hashSlash + hashSlashString.length) 422 | else if (isHashMode()) 423 | pathQuery = '/' 424 | else 425 | pathQuery = (location.pathname + location.search).slice(1) 426 | 427 | return util.normalizePathQuery(pathQuery) 428 | } 429 | 430 | function isHashMode() { 431 | return options.urlSync == 'hash' 432 | } 433 | 434 | /* 435 | * Compute a link that can be used in anchors' href attributes 436 | * from a state name and a list of params, a.k.a reverse routing. 437 | */ 438 | function link(stateName, params) { 439 | const state = leafStates[stateName] 440 | if (!state) throw new Error('Cannot find state ' + stateName) 441 | 442 | const interpolated = interpolate(state, params) 443 | const uri = util.normalizePathQuery(interpolated) 444 | 445 | return isHashMode() 446 | ? '#' + options.hashPrefix + uri 447 | : uri 448 | } 449 | 450 | function interpolate(state, params) { 451 | const encodedParams = {} 452 | 453 | for (let key in params) { 454 | if (params[key] !== undefined) 455 | encodedParams[key] = encodeURIComponent(params[key]) 456 | } 457 | 458 | return state.interpolate(encodedParams) 459 | } 460 | 461 | /* 462 | * Returns an object representing the current state of the router. 463 | */ 464 | function getCurrent() { 465 | return currentState && currentState.asPublic 466 | } 467 | 468 | /* 469 | * Returns an object representing the previous state of the router 470 | * or null if the router is still in its initial state. 471 | */ 472 | function getPrevious() { 473 | return previousState && previousState.asPublic 474 | } 475 | 476 | /* 477 | * Returns the diff between the current params and the previous ones. 478 | */ 479 | function getParamsDiff() { 480 | return currentParamsDiff 481 | } 482 | 483 | function allStatesRec(states, acc) { 484 | acc.push.apply(acc, states) 485 | states.forEach(state => allStatesRec(state.children, acc)) 486 | return acc 487 | } 488 | 489 | function allStates() { 490 | return allStatesRec(util.objectToArray(states), []) 491 | } 492 | 493 | /* 494 | * Returns the state object that was built with the given options object or that has the given fullName. 495 | * Returns undefined if the state doesn't exist. 496 | */ 497 | function findState(by) { 498 | const filterFn = (typeof by === 'object') 499 | ? state => by === state.options 500 | : state => by === state.fullName 501 | 502 | const state = allStates().filter(filterFn)[0] 503 | return state && state.asPublic 504 | } 505 | 506 | /* 507 | * Returns whether the router is executing its first transition. 508 | */ 509 | function isFirstTransition() { 510 | return previousState == null 511 | } 512 | 513 | function on(eventName, cb) { 514 | eventCallbacks[eventName] = cb 515 | return router 516 | } 517 | 518 | function stateTrees(states) { 519 | return util.mapValues(states, stateTree) 520 | } 521 | 522 | /* 523 | * Creates an internal State object from a specification POJO. 524 | */ 525 | function stateTree(state) { 526 | if (state.children) state.children = stateTrees(state.children) 527 | return State(state) 528 | } 529 | 530 | function logStateTree() { 531 | if (!logger.enabled) return 532 | 533 | function indent(level) { 534 | if (level == 0) return '' 535 | return new Array(2 + (level - 1) * 4).join(' ') + '── ' 536 | } 537 | 538 | const stateTree = function(state) { 539 | const path = util.normalizePathQuery(state.fullPath()) 540 | const pathStr = (state.children.length == 0) 541 | ? ' (@ path)'.replace('path', path) 542 | : '' 543 | const str = indent(state.parents.length) + state.name + pathStr + '\n' 544 | return str + state.children.map(stateTree).join('') 545 | } 546 | 547 | let msg = '\nState tree\n\n' 548 | msg += util.objectToArray(states).map(stateTree).join('') 549 | msg += '\n' 550 | 551 | logger.log(msg) 552 | } 553 | 554 | 555 | // Public methods 556 | 557 | router.configure = configure 558 | router.init = init 559 | router.transitionTo = transitionTo 560 | router.replaceParams = replaceParams 561 | router.backTo = backTo 562 | router.addState = addState 563 | router.link = link 564 | router.current = getCurrent 565 | router.previous = getPrevious 566 | router.findState = findState 567 | router.isFirstTransition = isFirstTransition 568 | router.paramsDiff = getParamsDiff 569 | router.options = options 570 | router.on = on 571 | 572 | // Used for testing purposes only 573 | router.urlPathQuery = urlPathQuery 574 | router.terminate = terminate 575 | 576 | util.mergeObjects(api, router) 577 | 578 | return router 579 | } 580 | 581 | 582 | // Logging 583 | 584 | const logger = { 585 | log: util.noop, 586 | error: util.noop, 587 | enabled: false 588 | } 589 | 590 | Router.enableLogs = function() { 591 | logger.enabled = true 592 | 593 | logger.log = function(...args) { 594 | const message = util.makeMessage.apply(null, args) 595 | console.log(message) 596 | } 597 | 598 | logger.error = function(...args) { 599 | const message = util.makeMessage.apply(null, args) 600 | console.error(message) 601 | } 602 | 603 | } 604 | 605 | 606 | export default Router 607 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | NOTE: Consider this project discontinued. It was created back when UI libs were terrible at replacing only part of the screen in a performant and maintenable manner. Nowadays, a simpler router with only top level routes is better. 2 | 3 | 4 | ![abyssa-logo](http://i171.photobucket.com/albums/u320/boubiyeah/abyssa-logo_zps745ae9eb.png) 5 | 6 | Hierarchical router library for single page applications. 7 | 8 | # Content 9 | * [Introduction](#introduction) 10 | * [Installation](#installation) 11 | * [Transitions](#transitions) 12 | * [API](#api) 13 | * [Router](#api-router) 14 | * [State](#api-state) 15 | * [StateWithParams](#api-stateWithParams) 16 | * [Anchor interception](#anchor-interception) 17 | * [Code examples](#code-examples) 18 | * [Cookbook](#cookbook) 19 | * [Removing router <-> state circular dependencies](#removingCircularDeps) 20 | * [Central router, modular states](#centralRouter) 21 | * [Handling the change of some params differently in `update`](#updateParamChanges) 22 | * [Integrating with React](#integratingWithReact) 23 | 24 | 25 | 26 | # Introduction 27 | 28 | ## What is Abyssa? 29 | 30 | Abyssa is a stateful, hierarchical client side router. 31 | What does stateful mean? It means all states are not equal and abyssa knows how to go from one state to another efficiently. 32 | Abyssa does only one thing: Routing. 33 | Upon entering a state, it can be rendered using any technique: Direct DOM manipulation, client or server side templating, with the help of a binding library, etc. 34 | A state can even be abstract and not render anything. 35 | 36 | ## Abyssa is versatile 37 | 38 | Abyssa can be used like a traditional stateless url -> callback router: 39 | 40 | ```javascript 41 | 42 | var show = { enter: articleEnter }; 43 | var edit = { enter: articleEditEnter }; 44 | 45 | Router({ 46 | article: State('articles/:id', show), 47 | articleEdit: State('articles/:id/edit', edit) 48 | }) 49 | .init(); 50 | ``` 51 | 52 | Or we can leverage abyssa's state machine nature and nest states when it serves us: 53 | 54 | ```javascript 55 | 56 | var article = { enter: loadArticle }; 57 | var show = { enter: articleEnter, exit: articleExit }; 58 | var edit = { enter: articleEditEnter, exit: articleEditExit }; 59 | 60 | Router({ 61 | article: State('articles/:id', article, { 62 | show: State('', show), 63 | edit: State('edit', edit) 64 | }) 65 | }) 66 | .init(); 67 | ``` 68 | 69 | Now we can freely switch between viewing and editing an article without any pause because the article data is loaded in the parent state and can be shared in the child states. 70 | 71 | ## Abyssa is performant 72 | 73 | What is the main advantage of stateful routers? Performance: Less redraws, less wasteful data loading, less wasteful setUp logic, etc. 74 | When going from a state A to a state B, as far as a stateless router is concerned, everything has to be done from scratch even if the two states are closely related. Trying to optimize state transitions by hand is going to be awkward and lead to an explosion of custom state variables. On the other hand, abyssa make it simple to reason about what makes each state different and thus compute the minimum set of changes needed to transition from state A to state B. 75 | 76 | ![transition-min-changes](http://i171.photobucket.com/albums/u320/boubiyeah/abyssaTransitionPic_zps1315690d.png) 77 | 78 | Here, Abyssa will simply swap the red bit for the green bit. Why should everything be redrawn? It's slower and the software would lose all the state implicitly stored in the previous DOM. 79 | 80 | Read this excellent blog post for more information: [Make the most of your routes](http://codebrief.com/2012/03/make-the-most-of-your-routes/) 81 | 82 | Note: With the emergence of VDOM approaches, using abyssa as a stateful router has less of an impact, as VDOM diffing/patching will usually take care of good enough performances. Also, a component based view library can handle hierarchical data loading and caching. 83 | 84 | 85 | # Installation 86 | 87 | **Using abyssa as a commonJS/browserify module** 88 | ``` 89 | npm install abyssa 90 | ... 91 | var Router = require('abyssa').Router; 92 | 93 | ``` 94 | 95 | **Using abyssa as a global** 96 | Use one of the provided prebuilt files in the target folder. 97 | 98 | 99 | 100 | # Transitions 101 | 102 | ## Example 103 | 104 | ![transition-example](http://i171.photobucket.com/albums/u320/boubiyeah/states1_zps7eb66af6.png) 105 | 106 | - There can be several root states. The router doesn't enforce the use of a single, top level state like some state machine implementations do. 107 | 108 | The transition from the state `A1` to the state `B` would consist of the following steps: 109 | 110 | **A1 exit -> PA exit -> B enter** 111 | 112 | 113 | 114 | # API 115 | 116 | 117 | ## Router 118 | 119 | 120 | ### configure (options: Object): Router 121 | Configure the router before its initialization. 122 | The available options are: 123 | - enableLogs: Whether (debug and error) console logs should be enabled. Defaults to false. 124 | - interceptAnchors: Whether anchor mousedown/clicks should be intercepted and trigger a state change. Defaults to true. 125 | - notFound: The State to enter when no state matching the current path query or name could be found. This is a string representing the fullName of an existing state. Defaults to null. 126 | - urlSync: How the router state and the URL should be kept in sync. Defaults to 'history'. Possible values are: 127 | - 'history': The router uses the history pushState API. 128 | - 'hash': The router uses the hash part of the URL for all browsers. 129 | - hashPrefix: Customize the hash separator. Set to '!' in order to have a hashbang like '/#!/'. Defaults to empty string. 130 | 131 | ### init (initState: String, initParams: Object): Router 132 | Initialize the router. 133 | The router will immediately initiate a transition to, in order of priority: 134 | 1) The init state passed as an argument (mostly useful for testing and debugging) 135 | 2) The state captured by the current URL 136 | 137 | ### addState (name: String, state: Object): Router 138 | Add a new root state to the router. 139 | Returns the router to allow chaining. 140 | The state Object is a simple POJO. See [State](#api-state) 141 | 142 | ### transitionTo (stateName: String, params: Object, acc: Object): void 143 | ### transitionTo (pathQuery: String, acc: Object): void 144 | Request a programmatic, synchronous state change. 145 | While you can change state programmatically, the more idiomatic way to do it is sometimes using anchor tags with the proper href. 146 | 147 | Two notations are supported: 148 | ```javascript 149 | // Fully qualified state name 150 | transitionTo('my.target.state', { id: 33, filter: 'desc' }) 151 | // Path and (optionally) query 152 | transitionTo('target/33?filter=desc') 153 | ``` 154 | The `acc` parameter can be used to specify an object that will be passed up then down every state involved in the transition. 155 | It can be used to share information from a state with the subsequent states. 156 | 157 | ### backTo (stateName: String, defaultParams: Object, acc: Object): void 158 | Attempt to navigate to 'stateName' with its previous params or 159 | fallback to the defaultParams parameter if the state was never entered. 160 | 161 | ### link (stateName: String, params: Object): String 162 | Compute a link that can be used in anchors' href attributes 163 | from a state name and a list of params, a.k.a reverse routing. 164 | 165 | ### previous(): [StateWithParams](#api-stateWithParams) 166 | Returns the previous state of the router or null if the router is still in its initial state. 167 | 168 | ### current(): [StateWithParams](#api-stateWithParams) 169 | Returns the current state of the router. 170 | 171 | ### findState(optionsOrFullName): State 172 | Returns the state object that was built with the given options Object or that has the given fullName String. 173 | Returns undefined if the state doesn't exist. 174 | 175 | ### isFirstTransition(): Boolean 176 | Returns whether the router is executing its first transition. 177 | 178 | ### replaceParams(params: Object): void 179 | Replaces the current state's params in the history with new params. 180 | Note: `replaceParams` only works with `urlSync` = `history`. 181 | The state is NOT exited/re-entered. That means you must store this params state outside the router to know 182 | what to render. This functionality is useful when some url changes shouldn't re-render the whole application, nor create a separate entry in the browser history. (ex: scroll position, active filters, whether a popup is visible) 183 | 184 | ### paramsDiff(): Object 185 | Returns the diff between the current params and the previous ones 186 | ```javascript 187 | var diff = router.paramsDiff(); 188 | 189 | { 190 | update: { // params staying but being updated 191 | id: true 192 | }, 193 | enter: { // params making an appearance 194 | q: true 195 | }, 196 | exit: { // params now gone 197 | section: true 198 | }, 199 | all: { // all param changes 200 | id: true, 201 | q: true, 202 | section: true 203 | } 204 | } 205 | ``` 206 | 207 | The paramsDiff is also accessible from the current state. 208 | 209 | ### Events 210 | 211 | All event handlers receive the current state and the old state as arguments (of type [StateWithParams](#api-stateWithParams)). 212 | 213 | #### router.on('started', handler) 214 | #### router.on('ended', handler) 215 | 216 | To remove the event handler, attach a null/undefined callback. 217 | 218 | 219 | ## State 220 | 221 | ### Basics 222 | States are simple POJOs used to build the router and represent path segments of an url (indeed, the router only matches routes against states' paths). 223 | 224 | A state can also own a list of query params: While all states will be able to read these params, isolated changes to these 225 | will only trigger a transition up to the state owning them (it will be exited and re-entered). The same applies to dynamic query params. 226 | How much you decompose your applications into states is completely up to you. 227 | 228 | ### Properties 229 | 230 | A state is really just an object with an `uri` property. Optionally, the following properties can be specified: 231 | `enter`, `exit`, `update`, `data`, `children`. 232 | 233 | #### uri: String 234 | The path segment this state owns. Can also contain a query string. Ex: `uri: 'articles/:id?filter'` 235 | 236 | #### enter (params: Object, value: Any, router: Router): void 237 | Specify a function that should be called when the state is entered. 238 | The params are the dynamic params (path and query alike in one object) of the current url. 239 | This is where you could render the data into the DOM or do some general work once for many child states. 240 | 241 | #### exit (params: Object, value: Any, router: Router): void 242 | Same as the enter function but called when the state is exited. 243 | This is where you could teardown any state or side effects introduced by the enter function, if needed. 244 | 245 | #### update (params: Object, value: Any, router: Router): void 246 | The update callback is called when the router is moving to the same state as the current state, but with different path/query params. 247 | Specifying an update callback can be seen as an optimization preventing doing wasteful work in exit/enter, e.g removing and adding the same DOM elements that were already present in the document before the state change. 248 | 249 | ```javascript 250 | var router = router({ 251 | people: State('people/:id', { 252 | enter: function() {}, 253 | update: function() {}, 254 | exit: function() {}, 255 | }) 256 | }).init('people/33'); 257 | ``` 258 | 259 | During init, `enter` will be called. 260 | 261 | Later, if the router transitions from 'people/33' to 'people/44', only `update` will be called. If an `update` callback wasn't specified, 262 | `exit` then `enter` would have been called in succession. 263 | 264 | #### data: Object 265 | Custom data properties can be specified declaratively when building the state. 266 | 267 | ### children: Object 268 | A map of child names to states. 269 | 270 | 271 | ### Usage examples 272 | 273 | #### Construction 274 | 275 | Given a state represented by the path "articles", with a child state named "item" represented by the dynamic path "id". 276 | When the router is in the state "articles.item" with the id param equal to 33, the browser url is http://yourdomain/articles/33. 277 | There are at least 3 ways to build such a router; It is advised to build the router centrally, even if the state definitions are 278 | located in their own modules. 279 | 280 | Using pojos 281 | ```javascript 282 | var router = Router({ 283 | articles: { 284 | uri: 'articles', 285 | children: { 286 | item: { 287 | uri: ':id' 288 | } 289 | } 290 | } 291 | }).init(); 292 | ``` 293 | Or using the `State` factory shorthand: 294 | ```javascript 295 | var router = Router({ 296 | articles: State('articles', {}, { 297 | item: State(':id', {}) 298 | }).init(); 299 | ``` 300 | 301 | 302 | Or using the imperative form: 303 | ```javascript 304 | var router = Router(); 305 | var articles = State('articles'); 306 | 307 | articles.children.item = State(':id'); 308 | router.addState(articles); 309 | router.init(); 310 | ``` 311 | 312 | 313 | #### Pathless states 314 | 315 | A state represented by the path "articles" with a path-less child state named "show" 316 | When the router is in the state "articles.show", the browser url is http://yourdomain/articles 317 | 318 | ```javascript 319 | var state = State('articles', {}, { 320 | show: State('') 321 | }); 322 | 323 | router.addState('articles', state); 324 | ``` 325 | 326 | #### Query strings 327 | 328 | Now the articles state also tells us it owns the query param named 'filter' in its state hierarchy. 329 | This means that any isolated change to the filter query param (meaning the filter was added, removed or changed but the path remained the same) is going to make that state exit and re-enter so that it can process the new filter value. If you do not specify which state owns the query param, all states above the currently selected state are exited and reentered, which can be less efficient. Also, Enumerating the possible query strings is mandatory if you want these to appear when using reverse routing or name-based state changes. 330 | ```javascript 331 | var state = State('articles?filter', {}, { 332 | show: State('') 333 | }); 334 | 335 | ``` 336 | 337 | #### Rest segments 338 | Additionaly, the last path segment of a state can end with a `*` to match any number of extra path segments: 339 | 340 | ```javascript 341 | State('path/:rest*') 342 | 343 | // All these state changes will result in that state being entered: 344 | 345 | // router.transitionTo('path'); // params.rest === undefined 346 | // router.transitionTo('path/other'); // params.rest === 'other' 347 | // router.transitionTo('path/other/yetAnother'); // params.rest === 'other/yetAnother' 348 | ``` 349 | 350 | 351 | ## StateWithParams 352 | `StateWithParams` objects are returned from `router.previous()`, `router.current()` and passed in event handlers. 353 | 354 | ### uri: String 355 | The current uri associated with this state 356 | 357 | ### params: Object 358 | The path and query params set for this state 359 | 360 | ### name: String 361 | The (local) name of the state 362 | 363 | ### fullName: String 364 | The fully qualified, unique name of the state 365 | 366 | ### isIn(fullName: String): Boolean 367 | Returns whether this state or any of its parents has the given fullName. 368 | 369 | 370 | 371 | # Anchor interception 372 | 373 | By default, the router will intercept anchor clicks and automatically navigate to a state if some conditions are met (left button pressed, href on the same domain, etc). 374 | This behavior can be turned off by using the corresponding router [configuration setting](#api-router) 375 | You may want to turn off anchor interception on mobile optimised apps and perform manual router.transitionTo() calls on touch/pointer events. 376 | 377 | You can also intercept mousedown events instead of the usual click events by using a data-attribute as follow: 378 | ``` 379 | 380 | ``` 381 | 382 | If a same-domain link should not be intercepted by Abyssa, you can use: 383 | ``` 384 | 385 | ``` 386 | 387 | 388 | 389 | # Code examples 390 | 391 | ## Demo app 392 | Demo: [Abyssa demo async](http://abyssa-async.herokuapp.com/) 393 | Source: [Abyssa demo async source](https://github.com/AlexGalays/abyssa-demo/tree/async/client) 394 | 395 | ## Abyssa + React 396 | [JSFiddle](http://jsfiddle.net/ku88Lcju/) 397 | 398 | 399 | # Cookbook 400 | 401 | 402 | ## Removing router <-> state circular dependencies 403 | 404 | States must be added to the router but states also often need to call methods on the router, for instance to create href links. 405 | This creates circular dependencies which are annoying when using primitive module systems such as CommonJS'. 406 | To break that circular dependency, simply require the api object instead of the router in your states: 407 | 408 | ```javascript 409 | var api = require('abyssa').api; 410 | 411 | // then api.link('state', { id: 123 }) 412 | 413 | ``` 414 | 415 | 416 | ## Central router, modular states 417 | It is much easier to reason about an application and its routes if the various uris can be all be read in one place instead of being spread all over the code base. However, states should be modularized for the sake of easier maintenance and separation of concerns. Here's how it might be achieved with CommonJS modules: 418 | 419 | ```javascript 420 | 421 | // router.js 422 | 423 | var Router = require('abyssa').Router; 424 | var State = require('abyssa').State; 425 | 426 | var index = require('./index'), 427 | articles = require('./articles'), 428 | articlesDetail = require('./articles/detail'), 429 | articlesDetailEdit = require('./articles/detailEdit'); 430 | 431 | Router({ 432 | 433 | index: State('', index), 434 | 435 | articles: State('articles', articles, { 436 | articlesDetail: State(':id/show', articlesDetail), 437 | articlesDetailEdit: State(':id/edit', articlesDetailEdit), 438 | }) 439 | 440 | }).init(); 441 | 442 | 443 | // index.js 444 | 445 | module.exports = { 446 | enter: function() { 447 | console.log('index entered'); 448 | }, 449 | exit: function() { 450 | console.log('index exited'); 451 | } 452 | }; 453 | 454 | 455 | ``` 456 | 457 | 458 | ## Handling the change of some params differently in `update` 459 | 460 | `update` is an optional hook that will be called whenever the router moves to the same state but with updated path/query params. 461 | 462 | However, not all params are equal: A change in the path param representing the resource id may induce more work than the change of some secondary query param. 463 | 464 | Example of a conditional update: 465 | 466 | ```javascript 467 | 468 | var api = require('abyssa').api; 469 | 470 | var state = State({ 471 | enter: function(params) { 472 | loadResourceForId(params.id); 473 | }, 474 | 475 | update: function(params) { 476 | var diff = api.paramsDiff(); 477 | 478 | // The id was changed 479 | if (diff.update.id) { 480 | loadResourceForId(params.id); 481 | } 482 | // Some other params were changed 483 | else { 484 | filterInPlace(params); 485 | } 486 | } 487 | }); 488 | 489 | ``` 490 | 491 | 492 | ## Integrating with React 493 | 494 | Check this [gist](https://gist.github.com/AlexGalays/f3ee01ff940defd147700c2725dd3976) to get a `ReactState` that can be used to automatically insert React children based on routing: 495 | -------------------------------------------------------------------------------- /test/unit-tests.js: -------------------------------------------------------------------------------- 1 | 2 | Router = Abyssa.Router 3 | State = Abyssa.State 4 | 5 | //Router.enableLogs() 6 | 7 | stubHistory() 8 | 9 | QUnit.testDone(function() { 10 | Abyssa.api.terminate() 11 | }) 12 | 13 | 14 | test('Simple states', function() { 15 | 16 | var events = [], 17 | lastArticleId, 18 | lastFilter 19 | 20 | var router = Router({ 21 | 22 | index: { 23 | enter: function() { 24 | events.push('indexEnter') 25 | }, 26 | 27 | exit: function() { 28 | events.push('indexExit') 29 | } 30 | }, 31 | 32 | articles: { 33 | uri: 'articles/:id?filter', 34 | 35 | enter: function(params) { 36 | events.push('articlesEnter') 37 | lastArticleId = params.id 38 | lastFilter = params.filter 39 | }, 40 | 41 | exit: function() { 42 | events.push('articlesExit') 43 | } 44 | } 45 | 46 | }).init('') 47 | 48 | deepEqual(events, ['indexEnter']) 49 | events = [] 50 | 51 | router.transitionTo('articles', { id: 38, filter: 'dark green' }) 52 | 53 | deepEqual(events, ['indexExit', 'articlesEnter']) 54 | strictEqual(lastArticleId, '38') 55 | strictEqual(lastFilter, 'dark green') 56 | events = [] 57 | 58 | router.transitionTo('index') 59 | 60 | deepEqual(events, ['articlesExit', 'indexEnter']) 61 | events = [] 62 | 63 | router.transitionTo('articles/44?filter=666') 64 | 65 | deepEqual(events, ['indexExit', 'articlesEnter']) 66 | strictEqual(lastArticleId, '44') 67 | strictEqual(lastFilter, '666') 68 | }) 69 | 70 | test('Simple states with shorthand function', function() { 71 | 72 | var events = [], 73 | lastArticleId, 74 | lastFilter 75 | 76 | var router = Router({ 77 | 78 | index: State('', { 79 | enter: function() { 80 | events.push('indexEnter') 81 | }, 82 | 83 | exit: function() { 84 | events.push('indexExit') 85 | } 86 | }), 87 | 88 | articles: State('articles/:id?filter', { 89 | enter: function(params) { 90 | events.push('articlesEnter') 91 | lastArticleId = params.id 92 | lastFilter = params.filter 93 | }, 94 | 95 | exit: function() { 96 | events.push('articlesExit') 97 | } 98 | }) 99 | 100 | }).init('') 101 | 102 | deepEqual(events, ['indexEnter']) 103 | events = [] 104 | 105 | router.transitionTo('articles', { id: 38, filter: 'dark green' }) 106 | 107 | deepEqual(events, ['indexExit', 'articlesEnter']) 108 | strictEqual(lastArticleId, '38') 109 | strictEqual(lastFilter, 'dark green') 110 | events = [] 111 | 112 | router.transitionTo('index') 113 | 114 | deepEqual(events, ['articlesExit', 'indexEnter']) 115 | events = [] 116 | 117 | router.transitionTo('articles/44?filter=666') 118 | 119 | deepEqual(events, ['indexExit', 'articlesEnter']) 120 | strictEqual(lastArticleId, '44') 121 | strictEqual(lastFilter, '666') 122 | }) 123 | 124 | 125 | test('Custom initial state', function() { 126 | 127 | var router = Router({ 128 | 129 | articles: State('articles/:id', {}, { 130 | edit: State('edit', { 131 | enter: function() { 132 | ok(true) 133 | } 134 | }) 135 | }) 136 | 137 | }).init('articles/33/edit') 138 | 139 | }) 140 | 141 | 142 | test('Multiple dynamic paths', function() { 143 | 144 | Router({ 145 | article: State('articles/:slug/:articleId', {}, { 146 | changeLogs: State('changelogs/:changeLogId', { 147 | enter: function(params) { 148 | equal(params.slug, 'le-roi-est-mort') 149 | equal(params.articleId, 127) 150 | equal(params.changeLogId, 5) 151 | } 152 | }) 153 | }) 154 | }).init('articles/le-roi-est-mort/127/changelogs/5') 155 | 156 | }) 157 | 158 | 159 | test('Nested state with pathless parents', function() { 160 | 161 | Router({ 162 | 163 | // articles and nature are abstract parent states 164 | articles: State('', {}, { 165 | nature: State('', {}, { 166 | edit: State('articles/nature/:id/edit', { 167 | enter: function() { 168 | ok(true) 169 | } 170 | }) 171 | }) 172 | }) 173 | 174 | }).init('articles/nature/88/edit') 175 | 176 | }) 177 | 178 | 179 | test('Missing state with a "notFound" state defined by its fullName', function() { 180 | 181 | var reachedNotFound 182 | 183 | var router = Router({ 184 | 185 | index: State(), 186 | 187 | articles: State('articles', {}, { 188 | nature: State('', {}, { 189 | edit: State('nature/:id/edit') 190 | }) 191 | }), 192 | 193 | iamNotFound: State('404', { 194 | enter: function() { reachedNotFound = true } 195 | }) 196 | 197 | }) 198 | .configure({ 199 | notFound: 'iamNotFound' 200 | }) 201 | .init('articles/naturess/88/edit') 202 | 203 | 204 | ok(reachedNotFound) 205 | 206 | router.transitionTo('') 207 | reachedNotFound = false 208 | 209 | // Should also work with the reverse routing notation 210 | router.transitionTo('articles.naturess.edit', { id: '88' }) 211 | 212 | ok(reachedNotFound) 213 | }) 214 | 215 | 216 | test('Missing state without a "notFound" state defined', function() { 217 | 218 | var router = Router({ 219 | 220 | index: State(), 221 | 222 | articles: State('articles', {}, { 223 | nature: State('', {}, { 224 | edit: State('nature/:id/edit') 225 | }) 226 | }), 227 | 228 | }).init('') 229 | 230 | throws(function() { 231 | router.transitionTo('articles/naturess/88/edit') 232 | }) 233 | 234 | // Also work with the reverse routing notation 235 | throws(function() { 236 | router.transitionTo('articles.naturess.edit', { id: '88' }) 237 | }) 238 | 239 | }) 240 | 241 | 242 | test('The router can be built bit by bit', function() { 243 | 244 | var reachedArticlesEdit, 245 | router = Router(), 246 | index = State(''), 247 | articles = State('articles'), 248 | edit = State('edit') 249 | 250 | edit.enter = function() { 251 | reachedArticlesEdit = true 252 | } 253 | 254 | articles.children.edit = edit 255 | 256 | router.addState('index', index) 257 | router.addState('articles', articles) 258 | router.init('articles.edit') 259 | 260 | ok(reachedArticlesEdit) 261 | }) 262 | 263 | 264 | test('More states can be added after router initialization', function() { 265 | var router = Router({ 266 | index: State('') 267 | }) 268 | 269 | router.init('') 270 | 271 | router.addState('articles', State('articles/:id', {}, { 272 | detail: State('detail') 273 | })) 274 | 275 | router.transitionTo('articles.detail', { id: '33' }) 276 | 277 | equal(router.current().fullName, 'articles.detail') 278 | }) 279 | 280 | 281 | test('Sibling states can not have the same path', function() { 282 | var router = Router({ 283 | index: State('index'), 284 | index2: State('index') 285 | }) 286 | 287 | throws(function() { router.init('/index') }) 288 | 289 | var nestedRouter = Router({ 290 | top: State('top', {}, { 291 | index: State('index'), 292 | index2: State('index') 293 | }) 294 | }) 295 | 296 | throws(function() { nestedRouter.init('top/index') }) 297 | }) 298 | 299 | 300 | test('State names must be unique among siblings', function() { 301 | var router, root 302 | 303 | router = Router() 304 | router.addState('root', State()) 305 | throws(function() { 306 | router.addState('root', State()) 307 | }) 308 | 309 | root = State() 310 | root.children.child = State() 311 | throws(function() { 312 | root.addState('child', State()) 313 | }) 314 | 315 | }) 316 | 317 | 318 | test('Ambiguous paths in different states are forbidden', function() { 319 | var router = Router({ 320 | books: State('', {}, { 321 | default: State('books', {}, {}) 322 | }), 323 | 324 | oldBooks: State('books', {}, { 325 | default: State('', {}, {}) 326 | }) 327 | }) 328 | 329 | throws(function() { router.init('books') }) 330 | }) 331 | 332 | 333 | test('Transitioning to a non leaf state is possible', function() { 334 | var events = [] 335 | 336 | function recordEvents(name) { 337 | return { 338 | enter: function() { events.push(name + 'Enter') }, 339 | exit: function() { events.push(name + 'Exit') } 340 | } 341 | } 342 | 343 | var router = Router({ 344 | index: State('index', recordEvents('index')), 345 | 346 | articles: State('', recordEvents('articles'), { 347 | item: State('articles/:id', recordEvents('item')) 348 | }) 349 | }).init('index') 350 | 351 | events = [] 352 | router.transitionTo('articles') 353 | 354 | deepEqual(events, ['indexExit', 'articlesEnter']) 355 | events = [] 356 | 357 | router.transitionTo('articles/33') 358 | 359 | deepEqual(events, ['itemEnter']) 360 | }) 361 | 362 | 363 | test('No transition occurs when going to the same state', function() { 364 | 365 | var events = [] 366 | var router = Router({ 367 | 368 | articles: State('articles/:id', { 369 | enter: function() { events.push('articlesEnter') }, 370 | exit: function() { events.push('articlesExit') }, 371 | }, { 372 | today: State('today', { 373 | enter: function() { events.push('todayEnter') }, 374 | exit: function() { events.push('todayExit') } 375 | }) 376 | }) 377 | 378 | }).init('articles/33/today') 379 | 380 | events = [] 381 | 382 | router.transitionTo('articles/33/today') 383 | deepEqual(events, []) 384 | 385 | }) 386 | 387 | 388 | test('Param and query changes should trigger a transition', function() { 389 | 390 | var events = [], 391 | lastArticleId 392 | 393 | var router = Router({ 394 | 395 | blog: State('blog', { 396 | enter: function() { 397 | events.push('blogEnter') 398 | }, 399 | 400 | exit: function() { 401 | events.push('blogExit') 402 | } 403 | }, { 404 | 405 | articles: State('articles/:id', { 406 | enter: function(params) { 407 | events.push('articlesEnter') 408 | lastArticleId = params.id 409 | }, 410 | 411 | exit: function() { 412 | events.push('articlesExit') 413 | }, 414 | }, { 415 | 416 | edit: State('edit', { 417 | enter: function() { 418 | events.push('editEnter') 419 | }, 420 | 421 | exit: function() { 422 | events.push('editExit') 423 | } 424 | }) 425 | }) 426 | }) 427 | 428 | }).init('blog/articles/33/edit') 429 | 430 | 431 | events = [] 432 | router.transitionTo('blog/articles/44/edit') 433 | 434 | // The transition only goes up to the state owning the param 435 | deepEqual(events, ['editExit', 'articlesExit', 'articlesEnter', 'editEnter']) 436 | events = [] 437 | 438 | router.transitionTo('blog/articles/44/edit?filter=1') 439 | 440 | // By default, a change in the query will result in a complete transition to the root state and back. 441 | deepEqual(events, ['editExit', 'articlesExit', 'blogExit', 'blogEnter', 'articlesEnter', 'editEnter']) 442 | events = [] 443 | 444 | router.transitionTo('blog/articles/44/edit?filter=2') 445 | 446 | deepEqual(events, ['editExit', 'articlesExit', 'blogExit', 'blogEnter', 'articlesEnter', 'editEnter']) 447 | }) 448 | 449 | 450 | test('Param changes in a leaf state should not trigger a parent transition', function() { 451 | 452 | var events = [], 453 | lastArticleId 454 | 455 | var router = Router({ 456 | 457 | blog: State('blog', { 458 | enter: function() { 459 | events.push('blogEnter') 460 | }, 461 | 462 | exit: function() { 463 | events.push('blogExit') 464 | } 465 | }, { 466 | articles: State('articles/:id', { 467 | enter: function(params) { 468 | events.push('articlesEnter') 469 | lastArticleId = params.id 470 | }, 471 | 472 | exit: function() { 473 | events.push('articlesExit') 474 | } 475 | 476 | }) 477 | }) 478 | 479 | }).init('/blog/articles/33') 480 | 481 | 482 | events = [] 483 | router.transitionTo('/blog/articles/44') 484 | 485 | // The transition only goes up to the state owning the param 486 | deepEqual(events, ['articlesExit', 'articlesEnter']) 487 | events = [] 488 | 489 | router.transitionTo('/blog/articles/44?filter=1') 490 | 491 | // By default, a change in the query will result in a complete transition to the root state and back. 492 | deepEqual(events, ['articlesExit', 'blogExit', 'blogEnter', 'articlesEnter']) 493 | events = [] 494 | 495 | router.transitionTo('/blog/articles/44?filter=2') 496 | 497 | deepEqual(events, ['articlesExit', 'blogExit', 'blogEnter', 'articlesEnter']) 498 | }) 499 | 500 | 501 | test('Query-only transitions', function() { 502 | 503 | var events = [] 504 | 505 | var router = Router({ 506 | 507 | blog: State('blog', { 508 | enter: function() { 509 | events.push('blogEnter') 510 | }, 511 | 512 | exit: function() { 513 | events.push('blogExit') 514 | } 515 | }, { 516 | 517 | // articles is the state that owns the filter query param 518 | articles: State('articles/:id?filter', { 519 | enter: function() { 520 | events.push('articlesEnter') 521 | }, 522 | 523 | exit: function() { 524 | events.push('articlesExit') 525 | } 526 | }, { 527 | 528 | edit: State('edit', { 529 | enter: function() { 530 | events.push('editEnter') 531 | }, 532 | 533 | exit: function() { 534 | events.push('editExit') 535 | } 536 | }) 537 | }) 538 | }) 539 | }).init('blog/articles/33/edit') 540 | 541 | 542 | setSomeUnknownQuery() 543 | fullTransitionOccurred() 544 | setFilterQuery() 545 | onlyExitedUpToStateOwningFilter() 546 | swapFilterValue() 547 | onlyExitedUpToStateOwningFilter() 548 | removeFilterQuery() 549 | onlyExitedUpToStateOwningFilter() 550 | 551 | 552 | function setSomeUnknownQuery() { 553 | events = [] 554 | router.transitionTo('blog/articles/33/edit?someQuery=true') 555 | } 556 | 557 | function fullTransitionOccurred() { 558 | deepEqual(events, ['editExit', 'articlesExit', 'blogExit', 'blogEnter', 'articlesEnter', 'editEnter']) 559 | } 560 | 561 | function setFilterQuery() { 562 | events = [] 563 | router.transitionTo('blog/articles/33/edit?filter=33') 564 | } 565 | 566 | function onlyExitedUpToStateOwningFilter() { 567 | deepEqual(events, ['editExit', 'articlesExit', 'articlesEnter', 'editEnter']) 568 | } 569 | 570 | function swapFilterValue() { 571 | events = [] 572 | router.transitionTo('blog/articles/33/edit?filter=34') 573 | } 574 | 575 | function removeFilterQuery() { 576 | events = [] 577 | router.transitionTo('blog/articles/33/edit') 578 | } 579 | 580 | }) 581 | 582 | 583 | test('Updating both a parent param and navigating to a different child state', function() { 584 | var events = [] 585 | var router = Router({ 586 | 587 | articles: State('articles/:id', { 588 | enter: function() { 589 | events.push('articlesEnter') 590 | }, 591 | 592 | exit: function() { 593 | events.push('articlesExit') 594 | } 595 | }, { 596 | 597 | edit: State('edit', { 598 | enter: function() { 599 | events.push('editEnter') 600 | }, 601 | 602 | exit: function() { 603 | events.push('editExit') 604 | } 605 | }) 606 | }) 607 | }).init('articles/33') 608 | 609 | events = [] 610 | 611 | router.transitionTo('articles/88/edit') 612 | 613 | deepEqual(events, ['articlesExit', 'articlesEnter', 'editEnter']) 614 | }) 615 | 616 | 617 | test('The query string is provided to all states', function() { 618 | 619 | Router({ 620 | one: State('one/:one', { 621 | enter: function(param) { 622 | assertions(param.one, 44, param) 623 | } 624 | }, { 625 | 626 | two: State('two/:two', { 627 | enter: function(param) { 628 | assertions(param.two, 'bla', param) 629 | } 630 | }, { 631 | 632 | three: State('three/:three', { 633 | enter: function(param) { 634 | assertions(param.three, 33, param) 635 | } 636 | }) 637 | }) 638 | }) 639 | }).init('one/44/two/bla/three/33?filter1=123&filter2=456') 640 | 641 | 642 | function assertions(param, expectedParam, queryObj) { 643 | equal(param, expectedParam) 644 | equal(queryObj.filter1, 123) 645 | equal(queryObj.filter2, 456) 646 | } 647 | 648 | }) 649 | 650 | 651 | test('Reverse routing', function() { 652 | var router = Router({ 653 | 654 | index: State(), 655 | 656 | one: State('one?filter&pizza&ble', {}, { 657 | two: State(':id/:color') 658 | }) 659 | 660 | }).init('') 661 | 662 | var href = router.link('one.two', { id: 33, color: 'dark green', filter: 'a&b', pizza: 123, bla: 55, ble: undefined }) 663 | equal(href, '/one/33/dark%20green?filter=a%26b&pizza=123') 664 | 665 | href = router.link('one.two', { id: 33, color: 'dark green' }) 666 | equal(href, '/one/33/dark%20green') 667 | 668 | }) 669 | 670 | 671 | test('Reverse routing in hash mode', function() { 672 | var router = Router({ 673 | 674 | index: State(), 675 | 676 | one: State('one?filter&pizza', {}, { 677 | two: State(':id/:color') 678 | }) 679 | 680 | }) 681 | .configure({ urlSync: 'hash', 'hashPrefix': '!' }) 682 | .init('') 683 | 684 | var href = router.link('one.two', {id: 33, color: 'dark green', filter: 'a&b', pizza: 123, bla: 55}) 685 | equal(href, '#!/one/33/dark%20green?filter=a%26b&pizza=123') 686 | 687 | href = router.link('one.two', {id: 33, color: 'dark green'}) 688 | equal(href, '#!/one/33/dark%20green') 689 | 690 | }) 691 | 692 | 693 | test('params should be decoded automatically', function() { 694 | var passedParams 695 | 696 | var router = Router({ 697 | 698 | index: State('index/:id/:filter', { enter: function(params) { 699 | passedParams = params 700 | }}) 701 | 702 | }).init('index/The%20midget%20%40/a%20b%20c') 703 | 704 | equal(passedParams.id, 'The midget @') 705 | equal(passedParams.filter, 'a b c') 706 | }) 707 | 708 | 709 | test('Init with a redirect', function() { 710 | var oldRouteChildEntered 711 | var oldRouteExited 712 | var newRouteEntered 713 | 714 | var router = Router({ 715 | 716 | oldRoute: State('oldRoute', { 717 | enter: function() { 718 | router.transitionTo('newRoute') 719 | }, 720 | exit: function() { oldRouteExited = true } 721 | }, { 722 | oldRouteChild: State('child', { enter: function() { oldRouteChildEntered = true } }) 723 | }), 724 | 725 | newRoute: State('newRoute', { enter: function() { newRouteEntered = true } }) 726 | 727 | }) 728 | router.init('oldRoute.oldRouteChild') 729 | 730 | equal(pushedStates.length, 1, 'Initiating with a redirection should not push a new state in history') 731 | 732 | ok(!oldRouteExited, 'The state was not properly entered as it redirected immediately. Therefore, it should not exit.') 733 | ok(!oldRouteChildEntered, 'A child state of a redirected route should not be entered') 734 | ok(newRouteEntered) 735 | }) 736 | 737 | 738 | test('redirect', function() { 739 | var oldRouteChildEntered 740 | var oldRouteExited 741 | var newRouteEntered 742 | 743 | var router = Router({ 744 | init: State('init'), 745 | 746 | oldRoute: State('oldRoute', { 747 | enter: function() { 748 | router.transitionTo('newRoute') 749 | }, 750 | exit: function() { oldRouteExited = true } 751 | }, { 752 | oldRouteChild: State('child', { enter: function() { oldRouteChildEntered = true } }) 753 | }), 754 | 755 | newRoute: State('newRoute', { enter: function() { newRouteEntered = true } }) 756 | 757 | }) 758 | 759 | router.init('init') 760 | router.transitionTo('oldRoute.oldRouteChild') 761 | 762 | equal(pushedStates.length, 2, 'A redirection should push a single history entry') 763 | 764 | ok(!oldRouteExited, 'The state was not properly entered as it redirected immediately. Therefore, it should not exit.') 765 | ok(!oldRouteChildEntered, 'A child state of a redirected route should not be entered') 766 | ok(newRouteEntered) 767 | }) 768 | 769 | 770 | test('Redirecting from transition.started', function() { 771 | 772 | var completedCount = 0 773 | 774 | var router = Router({ 775 | index: State(''), 776 | uno: State('uno', { enter: incrementCompletedCount }), 777 | dos: State('dos', { enter: incrementCompletedCount }) 778 | }) 779 | .init('') 780 | 781 | router.on('started', function() { 782 | router.on('started', null) 783 | router.transitionTo('dos') 784 | }) 785 | 786 | router.transitionTo('uno') 787 | 788 | equal(completedCount, 1) 789 | equal(router.current().name, 'dos') 790 | 791 | function incrementCompletedCount() { 792 | completedCount++ 793 | } 794 | }) 795 | 796 | 797 | test('rest params', function() { 798 | 799 | var lastParams 800 | 801 | var router = Router({ 802 | index: State(), 803 | colors: State('colors/:rest*', { enter: function(params) { 804 | lastParams = params 805 | }}) 806 | }).init('') 807 | 808 | 809 | router.transitionTo('colors') 810 | 811 | strictEqual(lastParams.rest, undefined) 812 | 813 | router.transitionTo('colors/red') 814 | 815 | strictEqual(lastParams.rest, 'red') 816 | 817 | router.transitionTo('colors/red/blue') 818 | 819 | strictEqual(lastParams.rest, 'red/blue') 820 | }) 821 | 822 | 823 | test('backTo', function() { 824 | var passedParams 825 | 826 | var router = Router({ 827 | 828 | articles: State('articles/:id?filter', { enter: rememberParams }), 829 | 830 | books: State('books'), 831 | 832 | cart: State('cart/:mode', { enter: rememberParams }) 833 | 834 | }).init('articles/33?filter=66') 835 | 836 | 837 | router.transitionTo('books') 838 | 839 | passedParams = null 840 | router.backTo('articles', { id: 1 }) 841 | 842 | strictEqual(passedParams.id, '33') 843 | strictEqual(passedParams.filter, '66') 844 | 845 | // We've never been to cart before, thus the default params we pass should be used 846 | router.backTo('cart', { mode: 'default' }) 847 | 848 | strictEqual(passedParams.mode, 'default') 849 | 850 | 851 | function rememberParams(params) { 852 | passedParams = params 853 | } 854 | }) 855 | 856 | 857 | test('update', function() { 858 | var events = [] 859 | var updateParams 860 | 861 | var root = RecordingState('root', '') 862 | var news = RecordingState('news', 'news/:id', root, true) 863 | var archive = RecordingState('archive', 'archive', news) 864 | var detail = RecordingState('detail', 'detail', archive, true) 865 | 866 | 867 | var router = Router({ 868 | root: root 869 | }) 870 | .init('root.news.archive.detail', { id: 33 }) 871 | 872 | 873 | deepEqual(events, [ 874 | 'rootEnter', 875 | 'newsEnter', 876 | 'archiveEnter', 877 | 'detailEnter' 878 | ]) 879 | 880 | events = [] 881 | updateParams = null 882 | router.transitionTo('root.news.archive.detail', { id: 34 }) 883 | 884 | deepEqual(events, [ 885 | 'archiveExit', 886 | 'newsUpdate', 887 | 'archiveEnter', 888 | 'detailUpdate' 889 | ]) 890 | strictEqual(updateParams.id, '34') 891 | 892 | 893 | function RecordingState(name, path, parent, withUpdate) { 894 | var state = State(path, { 895 | enter: function(params) { events.push(name + 'Enter') }, 896 | exit: function() { events.push(name + 'Exit') } 897 | }) 898 | 899 | if (withUpdate) state.update = function(params) { 900 | events.push(name + 'Update') 901 | updateParams = params 902 | } 903 | 904 | if (parent) parent.children[name] = state 905 | 906 | return state 907 | } 908 | 909 | }) 910 | 911 | 912 | function stateWithParamsAssertions(stateWithParams) { 913 | equal(stateWithParams.uri, '/state1/33/misc?filter=true') 914 | equal(stateWithParams.name, 'state1Child') 915 | equal(stateWithParams.fullName, 'state1.state1Child') 916 | equal(stateWithParams.data.dd, 12) 917 | 918 | equal(stateWithParams.params.id, '33') 919 | equal(stateWithParams.params.category, 'misc') 920 | equal(stateWithParams.params.filter, 'true') 921 | 922 | equal(stateWithParams.paramsDiff.enter.filter, true) 923 | equal(stateWithParams.paramsDiff.all.filter, true) 924 | 925 | ok(stateWithParams.isIn('state1')) 926 | ok(stateWithParams.isIn('state1.state1Child')) 927 | ok(!stateWithParams.isIn('state2')) 928 | } 929 | 930 | test('event handlers are passed StateWithParams objects', function() { 931 | 932 | var router = Router({ 933 | 934 | state1: State('state1/:id', {}, { 935 | state1Child: State(':category', { data: { dd: 12 } }) 936 | }), 937 | 938 | state2: State('state2/:country/:city') 939 | }) 940 | 941 | router.on('started', function(newState) { 942 | router.on('started', null) 943 | stateWithParamsAssertions(newState) 944 | }) 945 | 946 | router.init('state1/33/misc?filter=true') 947 | }) 948 | 949 | 950 | test('router.current and router.previous', function() { 951 | 952 | var router = Router({ 953 | 954 | state1: State('state1/:id', {}, { 955 | state1Child: State(':category', { 956 | data: { dd: 12 }, 957 | enter: assertions 958 | }) 959 | }), 960 | 961 | state2: State('state2/:country/:city') 962 | 963 | }) 964 | 965 | router.init('state1/33/misc?filter=true') 966 | 967 | 968 | function assertions() { 969 | var state = router.current() 970 | stateWithParamsAssertions(state) 971 | 972 | equal(router.previous(), null) 973 | 974 | router.transitionTo('state2/england/london') 975 | 976 | var previous = router.previous() 977 | equal(previous, state) 978 | stateWithParamsAssertions(previous) 979 | 980 | equal(router.current().fullName, 'state2') 981 | } 982 | 983 | }) 984 | 985 | 986 | test('router.findState', function() { 987 | var state1 = { 988 | uri: 'articles', 989 | enter: function() {}, 990 | children: { 991 | detail: { 992 | uri: ':id?q', 993 | data: { kk: 'bb' } 994 | } 995 | }, 996 | data: { kk: 'aa' } 997 | } 998 | 999 | var state2 = { 1000 | uri: 'index', 1001 | children: { 1002 | dashboard: { 1003 | uri: 'dashboard' 1004 | }, 1005 | stats: { 1006 | uri: 'stats' 1007 | } 1008 | } 1009 | } 1010 | 1011 | var router = Router({ 1012 | state1: state1, 1013 | state2: state2 1014 | }) 1015 | .init('state1') 1016 | 1017 | function assertStateApi(stateApi, name, fullName, parentFullName, data) { 1018 | equal(stateApi.name, name) 1019 | equal(stateApi.fullName, fullName) 1020 | equal((stateApi.parent && stateApi.parent.fullName), parentFullName) 1021 | equal(stateApi.data.kk, data) 1022 | equal(Object.keys(stateApi).length, 4) 1023 | } 1024 | 1025 | var state1Api = router.findState(state1) 1026 | var state1Api2 = router.findState('state1') 1027 | assertStateApi(state1Api, 'state1', 'state1', undefined, 'aa') 1028 | equal(state1Api, state1Api2) 1029 | 1030 | var state1DetailApi = router.findState('state1.detail') 1031 | assertStateApi(state1DetailApi, 'detail', 'state1.detail', 'state1', 'bb') 1032 | 1033 | equal(router.findState('nope'), undefined) 1034 | }) 1035 | 1036 | 1037 | test('urls can contain dots', function() { 1038 | 1039 | Router({ 1040 | map: State('map/:lat/:lon', { enter: function(params) { 1041 | strictEqual(params.lat, '1.5441') 1042 | strictEqual(params.lon, '0.9986') 1043 | }}) 1044 | }).init('map/1.5441/0.9986') 1045 | 1046 | }) 1047 | 1048 | 1049 | test('util.normalizePathQuery', function() { 1050 | 1051 | function expect(from, to) { 1052 | var assertMessage = ('"' + from + '" => "' + to + '"') 1053 | equal(Abyssa.util.normalizePathQuery(from), to, assertMessage) 1054 | } 1055 | 1056 | // No slash changes required 1057 | expect("/", "/") 1058 | expect("/path", "/path") 1059 | expect("/path/a/b/c", "/path/a/b/c") 1060 | expect("/path?query", "/path?query") 1061 | expect("/path/a/b/c?query", "/path/a/b/c?query") 1062 | expect("/path/a/b/c?query=///", "/path/a/b/c?query=///") 1063 | 1064 | // Slashes are added 1065 | expect("", "/") 1066 | expect("path", "/path") 1067 | expect("path/a/b/c", "/path/a/b/c") 1068 | expect("path?query", "/path?query") 1069 | expect("path/a/b/c?query", "/path/a/b/c?query") 1070 | expect("?query", "/?query") 1071 | 1072 | // Slashes are removed 1073 | expect("//", "/") 1074 | expect("///", "/") 1075 | expect("//path", "/path") 1076 | expect("//path/a/b/c", "/path/a/b/c") 1077 | expect("//path/", "/path") 1078 | expect("//path/a/b/c/", "/path/a/b/c") 1079 | expect("//path//", "/path") 1080 | expect("//path/a/b/c//", "/path/a/b/c") 1081 | expect("/path//", "/path") 1082 | expect("/path/a/b/c//", "/path/a/b/c") 1083 | expect("//path?query", "/path?query") 1084 | expect("//path/a/b/c?query", "/path/a/b/c?query") 1085 | expect("/path/?query", "/path?query") 1086 | expect("/path/a/b/c/?query", "/path/a/b/c?query") 1087 | expect("/path//?query", "/path?query") 1088 | expect("/path/a/b/c//?query", "/path/a/b/c?query") 1089 | }) 1090 | 1091 | 1092 | test('can prevent a transition by navigating to self from the exit handler', function() { 1093 | 1094 | var events = [] 1095 | 1096 | var router = Router({ 1097 | uno: State('uno', { 1098 | enter: function() { events.push('unoEnter') }, 1099 | exit: function() { router.transitionTo('uno') } 1100 | }), 1101 | dos: State('dos', { 1102 | enter: function() { events.push('dosEnter') }, 1103 | exit: function() { events.push('dosExit') } 1104 | }) 1105 | }) 1106 | .init('uno') 1107 | 1108 | router.transitionTo('dos') 1109 | // Only the initial event is here. 1110 | // Since the exit was interrupted, there's no reason to re-enter. 1111 | deepEqual(events, ['unoEnter']) 1112 | equal(router.current().name, 'uno') 1113 | }) 1114 | 1115 | 1116 | test('to break circular dependencies, the api object can be used instead of the router', function() { 1117 | 1118 | var router = Abyssa.api 1119 | var events = [] 1120 | 1121 | Router({ 1122 | 1123 | index: { 1124 | enter: function() { 1125 | events.push('indexEnter') 1126 | }, 1127 | 1128 | exit: function() { 1129 | events.push('indexExit') 1130 | } 1131 | }, 1132 | 1133 | articles: { 1134 | uri: 'articles/:id?filter', 1135 | 1136 | enter: function(params) { 1137 | events.push('articlesEnter') 1138 | }, 1139 | 1140 | exit: function() { 1141 | events.push('articlesExit') 1142 | } 1143 | } 1144 | 1145 | }).init('') 1146 | 1147 | events = [] 1148 | router.transitionTo('articles/33') 1149 | 1150 | deepEqual(events, ['indexExit', 'articlesEnter']) 1151 | }) 1152 | 1153 | 1154 | test('All state callbacks are passed an accumulator object and the router instance', function() { 1155 | 1156 | var router = Abyssa.api 1157 | 1158 | Router({ 1159 | 1160 | articles: { 1161 | uri: 'articles', 1162 | enter: function(params, acc, router) { 1163 | deepEqual(acc, {}) 1164 | ok(router.link !== undefined) 1165 | acc.fromParent = 123 1166 | }, 1167 | 1168 | children: { 1169 | detail: { 1170 | uri: ':id', 1171 | enter: function(params, acc) { 1172 | deepEqual(acc, { fromParent: 123 }) 1173 | } 1174 | } 1175 | } 1176 | } 1177 | 1178 | }).init('articles/33') 1179 | }) 1180 | 1181 | 1182 | test('a custom accumulator object can be passed to all states', function() { 1183 | 1184 | var myAcc = {} 1185 | 1186 | var router = Abyssa.api 1187 | 1188 | Router({ 1189 | 1190 | index: State('', {}), 1191 | 1192 | articles: { 1193 | uri: 'articles', 1194 | enter: function(params, acc) { 1195 | equal(myAcc, acc) 1196 | acc.fromParent = 123 1197 | }, 1198 | 1199 | children: { 1200 | detail: { 1201 | uri: ':id', 1202 | enter: function(params, acc) { 1203 | equal(myAcc, acc) 1204 | deepEqual(acc, { fromParent: 123 }) 1205 | } 1206 | } 1207 | } 1208 | } 1209 | 1210 | }).init('') 1211 | 1212 | router.transitionTo('articles/33', myAcc) 1213 | }) 1214 | 1215 | 1216 | test('The public fullName of a _default_ state is the same as its parent', function() { 1217 | var router = Abyssa.api 1218 | 1219 | Router({ 1220 | 1221 | articles: { 1222 | uri: 'articles', 1223 | 1224 | children: { 1225 | detail: { 1226 | uri: ':id', 1227 | 1228 | children: { 1229 | moreDetails: { 1230 | uri: 'moreDetails' 1231 | } 1232 | } 1233 | } 1234 | } 1235 | } 1236 | 1237 | }).init('articles/33') 1238 | 1239 | // The router is actually at articles.detail._default_ but that should be an implementation detail. 1240 | equal(router.current().fullName, 'articles.detail') 1241 | }) 1242 | 1243 | 1244 | const pushedStates = [{}] //contains initial state in history 1245 | 1246 | function stubHistory() { 1247 | QUnit.testStart(function(){ 1248 | pushedStates.splice(0, pushedStates.length, {}) 1249 | }) 1250 | 1251 | window.history.pushState = function(state, title, url) { 1252 | pushedStates.push({ 1253 | state: state, 1254 | title: title, 1255 | url: url 1256 | }) 1257 | } 1258 | window.history.replaceState = function(state, title, url) { 1259 | pushedStates.pop() 1260 | pushedStates.push({ 1261 | state: state, 1262 | title: title, 1263 | url: url 1264 | }) 1265 | } 1266 | } 1267 | -------------------------------------------------------------------------------- /global/abyssa.js: -------------------------------------------------------------------------------- 1 | (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Abyssa = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o -1) pathQuery = location.href.slice(hashSlash + hashSlashString.length);else if (isHashMode()) pathQuery = '/';else pathQuery = (location.pathname + location.search).slice(1); 432 | 433 | return util.normalizePathQuery(pathQuery); 434 | } 435 | 436 | function isHashMode() { 437 | return options.urlSync == 'hash'; 438 | } 439 | 440 | /* 441 | * Compute a link that can be used in anchors' href attributes 442 | * from a state name and a list of params, a.k.a reverse routing. 443 | */ 444 | function link(stateName, params) { 445 | var state = leafStates[stateName]; 446 | if (!state) throw new Error('Cannot find state ' + stateName); 447 | 448 | var interpolated = interpolate(state, params); 449 | var uri = util.normalizePathQuery(interpolated); 450 | 451 | return isHashMode() ? '#' + options.hashPrefix + uri : uri; 452 | } 453 | 454 | function interpolate(state, params) { 455 | var encodedParams = {}; 456 | 457 | for (var key in params) { 458 | if (params[key] !== undefined) encodedParams[key] = encodeURIComponent(params[key]); 459 | } 460 | 461 | return state.interpolate(encodedParams); 462 | } 463 | 464 | /* 465 | * Returns an object representing the current state of the router. 466 | */ 467 | function getCurrent() { 468 | return currentState && currentState.asPublic; 469 | } 470 | 471 | /* 472 | * Returns an object representing the previous state of the router 473 | * or null if the router is still in its initial state. 474 | */ 475 | function getPrevious() { 476 | return previousState && previousState.asPublic; 477 | } 478 | 479 | /* 480 | * Returns the diff between the current params and the previous ones. 481 | */ 482 | function getParamsDiff() { 483 | return currentParamsDiff; 484 | } 485 | 486 | function allStatesRec(states, acc) { 487 | acc.push.apply(acc, states); 488 | states.forEach(function (state) { 489 | return allStatesRec(state.children, acc); 490 | }); 491 | return acc; 492 | } 493 | 494 | function allStates() { 495 | return allStatesRec(util.objectToArray(states), []); 496 | } 497 | 498 | /* 499 | * Returns the state object that was built with the given options object or that has the given fullName. 500 | * Returns undefined if the state doesn't exist. 501 | */ 502 | function findState(by) { 503 | var filterFn = (typeof by === 'undefined' ? 'undefined' : _typeof(by)) === 'object' ? function (state) { 504 | return by === state.options; 505 | } : function (state) { 506 | return by === state.fullName; 507 | }; 508 | 509 | var state = allStates().filter(filterFn)[0]; 510 | return state && state.asPublic; 511 | } 512 | 513 | /* 514 | * Returns whether the router is executing its first transition. 515 | */ 516 | function isFirstTransition() { 517 | return previousState == null; 518 | } 519 | 520 | function on(eventName, cb) { 521 | eventCallbacks[eventName] = cb; 522 | return router; 523 | } 524 | 525 | function stateTrees(states) { 526 | return util.mapValues(states, stateTree); 527 | } 528 | 529 | /* 530 | * Creates an internal State object from a specification POJO. 531 | */ 532 | function stateTree(state) { 533 | if (state.children) state.children = stateTrees(state.children); 534 | return (0, _State2.default)(state); 535 | } 536 | 537 | function logStateTree() { 538 | if (!logger.enabled) return; 539 | 540 | function indent(level) { 541 | if (level == 0) return ''; 542 | return new Array(2 + (level - 1) * 4).join(' ') + '── '; 543 | } 544 | 545 | var stateTree = function stateTree(state) { 546 | var path = util.normalizePathQuery(state.fullPath()); 547 | var pathStr = state.children.length == 0 ? ' (@ path)'.replace('path', path) : ''; 548 | var str = indent(state.parents.length) + state.name + pathStr + '\n'; 549 | return str + state.children.map(stateTree).join(''); 550 | }; 551 | 552 | var msg = '\nState tree\n\n'; 553 | msg += util.objectToArray(states).map(stateTree).join(''); 554 | msg += '\n'; 555 | 556 | logger.log(msg); 557 | } 558 | 559 | // Public methods 560 | 561 | router.configure = configure; 562 | router.init = init; 563 | router.transitionTo = transitionTo; 564 | router.replaceParams = replaceParams; 565 | router.backTo = backTo; 566 | router.addState = addState; 567 | router.link = link; 568 | router.current = getCurrent; 569 | router.previous = getPrevious; 570 | router.findState = findState; 571 | router.isFirstTransition = isFirstTransition; 572 | router.paramsDiff = getParamsDiff; 573 | router.options = options; 574 | router.on = on; 575 | 576 | // Used for testing purposes only 577 | router.urlPathQuery = urlPathQuery; 578 | router.terminate = terminate; 579 | 580 | util.mergeObjects(_api2.default, router); 581 | 582 | return router; 583 | } 584 | 585 | // Logging 586 | 587 | var logger = { 588 | log: util.noop, 589 | error: util.noop, 590 | enabled: false 591 | }; 592 | 593 | Router.enableLogs = function () { 594 | logger.enabled = true; 595 | 596 | logger.log = function () { 597 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 598 | args[_key] = arguments[_key]; 599 | } 600 | 601 | var message = util.makeMessage.apply(null, args); 602 | console.log(message); 603 | }; 604 | 605 | logger.error = function () { 606 | for (var _len2 = arguments.length, args = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { 607 | args[_key2] = arguments[_key2]; 608 | } 609 | 610 | var message = util.makeMessage.apply(null, args); 611 | console.error(message); 612 | }; 613 | }; 614 | 615 | exports.default = Router; 616 | 617 | },{"./State":2,"./StateWithParams":3,"./Transition":4,"./anchors":5,"./api":6,"./util":8}],2:[function(require,module,exports){ 618 | 'use strict'; 619 | 620 | Object.defineProperty(exports, "__esModule", { 621 | value: true 622 | }); 623 | 624 | var _util = require('./util'); 625 | 626 | var util = _interopRequireWildcard(_util); 627 | 628 | function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } 629 | 630 | var PARAMS = /:[^\\?\/]*/g; 631 | 632 | /* 633 | * Creates a new State instance from a {uri, enter, exit, update, children} object. 634 | * This is the internal representation of a state used by the router. 635 | */ 636 | function State(options) { 637 | var state = { options: options }; 638 | var states = options.children; 639 | 640 | state.path = pathFromURI(options.uri); 641 | state.params = paramsFromURI(options.uri); 642 | state.queryParams = queryParamsFromURI(options.uri); 643 | state.states = states; 644 | state.data = options.data; 645 | 646 | state.enter = options.enter || util.noop; 647 | state.update = options.update; 648 | state.exit = options.exit || util.noop; 649 | 650 | /* 651 | * Initialize and freeze this state. 652 | */ 653 | function init(router, name, parent) { 654 | state.router = router; 655 | state.name = name; 656 | state.isDefault = name == '_default_'; 657 | state.parent = parent; 658 | state.parents = getParents(); 659 | state.root = state.parent ? state.parents[state.parents.length - 1] : state; 660 | state.children = util.objectToArray(states); 661 | state.fullName = getFullName(); 662 | state.asPublic = makePublicAPI(); 663 | 664 | eachChildState(function (name, childState) { 665 | childState.init(router, name, state); 666 | }); 667 | } 668 | 669 | /* 670 | * The full path, composed of all the individual paths of this state and its parents. 671 | */ 672 | function fullPath() { 673 | var result = state.path; 674 | var stateParent = state.parent; 675 | 676 | while (stateParent) { 677 | if (stateParent.path) result = stateParent.path + '/' + result; 678 | stateParent = stateParent.parent; 679 | } 680 | 681 | return result; 682 | } 683 | 684 | /* 685 | * The list of all parents, starting from the closest ones. 686 | */ 687 | function getParents() { 688 | var parents = []; 689 | var parent = state.parent; 690 | 691 | while (parent) { 692 | parents.push(parent); 693 | parent = parent.parent; 694 | } 695 | 696 | return parents; 697 | } 698 | 699 | /* 700 | * The fully qualified name of this state. 701 | * e.g granparentName.parentName.name 702 | */ 703 | function getFullName() { 704 | var result = state.parents.reduceRight(function (acc, parent) { 705 | return acc + parent.name + '.'; 706 | }, '') + state.name; 707 | 708 | return state.isDefault ? result.replace('._default_', '') : result; 709 | } 710 | 711 | function allQueryParams() { 712 | return state.parents.reduce(function (acc, parent) { 713 | return util.mergeObjects(acc, parent.queryParams); 714 | }, util.copyObject(state.queryParams)); 715 | } 716 | 717 | function makePublicAPI() { 718 | return { 719 | name: state.name, 720 | fullName: state.fullName, 721 | data: options.data || {}, 722 | parent: state.parent && state.parent.asPublic 723 | }; 724 | } 725 | 726 | function eachChildState(callback) { 727 | for (var name in states) { 728 | callback(name, states[name]); 729 | } 730 | } 731 | 732 | /* 733 | * Returns whether this state matches the passed path Array. 734 | * In case of a match, the actual param values are returned. 735 | */ 736 | function matches(paths) { 737 | var params = {}; 738 | var nonRestStatePaths = state.paths.filter(function (p) { 739 | return p[p.length - 1] !== '*'; 740 | }); 741 | 742 | /* This state has more paths than the passed paths, it cannot be a match */ 743 | if (nonRestStatePaths.length > paths.length) return false; 744 | 745 | /* Checks if the paths match one by one */ 746 | for (var i = 0; i < paths.length; i++) { 747 | var path = paths[i]; 748 | var thatPath = state.paths[i]; 749 | 750 | /* This state has less paths than the passed paths, it cannot be a match */ 751 | if (!thatPath) return false; 752 | 753 | var isRest = thatPath[thatPath.length - 1] === '*'; 754 | if (isRest) { 755 | var name = paramName(thatPath); 756 | params[name] = paths.slice(i).join('/'); 757 | return params; 758 | } 759 | 760 | var isDynamic = thatPath[0] === ':'; 761 | if (isDynamic) { 762 | var _name = paramName(thatPath); 763 | params[_name] = path; 764 | } else if (thatPath != path) return false; 765 | } 766 | 767 | return params; 768 | } 769 | 770 | /* 771 | * Returns a URI built from this state and the passed params. 772 | */ 773 | function interpolate(params) { 774 | var path = state.fullPath().replace(PARAMS, function (p) { 775 | return params[paramName(p)] || ''; 776 | }); 777 | 778 | var queryParams = allQueryParams(); 779 | var passedQueryParams = Object.keys(params).filter(function (p) { 780 | return queryParams[p]; 781 | }); 782 | 783 | var query = passedQueryParams.map(function (p) { 784 | return p + '=' + params[p]; 785 | }).join('&'); 786 | 787 | return path + (query.length ? '?' + query : ''); 788 | } 789 | 790 | function toString() { 791 | return state.fullName; 792 | } 793 | 794 | state.init = init; 795 | state.fullPath = fullPath; 796 | state.allQueryParams = allQueryParams; 797 | state.matches = matches; 798 | state.interpolate = interpolate; 799 | state.toString = toString; 800 | 801 | return state; 802 | } 803 | 804 | function paramName(param) { 805 | return param[param.length - 1] === '*' ? param.substr(1).slice(0, -1) : param.substr(1); 806 | } 807 | 808 | function pathFromURI(uri) { 809 | return (uri || '').split('?')[0]; 810 | } 811 | 812 | function paramsFromURI(uri) { 813 | var matches = PARAMS.exec(uri); 814 | return matches ? util.arrayToObject(matches.map(paramName)) : {}; 815 | } 816 | 817 | function queryParamsFromURI(uri) { 818 | var query = (uri || '').split('?')[1]; 819 | return query ? util.arrayToObject(query.split('&')) : {}; 820 | } 821 | 822 | exports.default = State; 823 | 824 | },{"./util":8}],3:[function(require,module,exports){ 825 | 'use strict'; 826 | 827 | Object.defineProperty(exports, "__esModule", { 828 | value: true 829 | }); 830 | exports.default = StateWithParams; 831 | /* 832 | * Creates a new StateWithParams instance. 833 | * 834 | * StateWithParams is the merge between a State object (created and added to the router before init) 835 | * and params (both path and query params, extracted from the URL after init) 836 | * 837 | * This is an internal model The public model is the asPublic property. 838 | */ 839 | function StateWithParams(state, params, pathQuery, diff) { 840 | return { 841 | state: state, 842 | params: params, 843 | toString: toString, 844 | asPublic: makePublicAPI(state, params, pathQuery, diff) 845 | }; 846 | } 847 | 848 | function makePublicAPI(state, params, pathQuery, paramsDiff) { 849 | 850 | /* 851 | * Returns whether this state or any of its parents has the given fullName. 852 | */ 853 | function isIn(fullStateName) { 854 | var current = state; 855 | while (current) { 856 | if (current.fullName == fullStateName) return true; 857 | current = current.parent; 858 | } 859 | return false; 860 | } 861 | 862 | return { 863 | uri: pathQuery, 864 | params: params, 865 | paramsDiff: paramsDiff, 866 | name: state ? state.name : '', 867 | fullName: state ? state.fullName : '', 868 | data: state ? state.data : {}, 869 | isIn: isIn 870 | }; 871 | } 872 | 873 | function toString() { 874 | var name = this.state && this.state.fullName; 875 | return name + ':' + JSON.stringify(this.params); 876 | } 877 | 878 | },{}],4:[function(require,module,exports){ 879 | 'use strict'; 880 | 881 | Object.defineProperty(exports, "__esModule", { 882 | value: true 883 | }); 884 | /* 885 | * Create a new Transition instance. 886 | */ 887 | function Transition(fromStateWithParams, toStateWithParams, paramsDiff, acc, router, logger) { 888 | var root = { root: null, inclusive: true }; 889 | var enters = void 0; 890 | var exits = void 0; 891 | 892 | var fromState = fromStateWithParams && fromStateWithParams.state; 893 | var toState = toStateWithParams.state; 894 | var params = toStateWithParams.params; 895 | var isUpdate = fromState == toState; 896 | 897 | var transition = { 898 | from: fromState, 899 | to: toState, 900 | toParams: params, 901 | cancel: cancel, 902 | run: run, 903 | cancelled: false, 904 | currentState: fromState 905 | }; 906 | 907 | // The first transition has no fromState. 908 | if (fromState) root = transitionRoot(fromState, toState, isUpdate, paramsDiff); 909 | 910 | exits = fromState ? transitionStates(fromState, root) : []; 911 | enters = transitionStates(toState, root).reverse(); 912 | 913 | function run() { 914 | startTransition(enters, exits, params, transition, isUpdate, acc, router, logger); 915 | } 916 | 917 | function cancel() { 918 | transition.cancelled = true; 919 | } 920 | 921 | return transition; 922 | } 923 | 924 | function startTransition(enters, exits, params, transition, isUpdate, acc, router, logger) { 925 | acc = acc || {}; 926 | 927 | transition.exiting = true; 928 | exits.forEach(function (state) { 929 | if (isUpdate && state.update) return; 930 | runStep(state, 'exit', params, transition, acc, router, logger); 931 | }); 932 | transition.exiting = false; 933 | 934 | enters.forEach(function (state) { 935 | var fn = isUpdate && state.update ? 'update' : 'enter'; 936 | runStep(state, fn, params, transition, acc, router, logger); 937 | }); 938 | } 939 | 940 | function runStep(state, stepFn, params, transition, acc, router, logger) { 941 | if (transition.cancelled) return; 942 | 943 | if (logger.enabled) { 944 | var capitalizedStep = stepFn[0].toUpperCase() + stepFn.slice(1); 945 | logger.log(capitalizedStep + ' ' + state.fullName); 946 | } 947 | 948 | var result = state[stepFn](params, acc, router); 949 | 950 | if (transition.cancelled) return; 951 | 952 | transition.currentState = stepFn == 'exit' ? state.parent : state; 953 | 954 | return result; 955 | } 956 | 957 | /* 958 | * The top-most fromState's parent that must be exited 959 | * or undefined if the two states are in distinct branches of the tree. 960 | */ 961 | function transitionRoot(fromState, toState, isUpdate, paramsDiff) { 962 | var closestCommonParent = void 0; 963 | 964 | var parents = [fromState].concat(fromState.parents).reverse(); 965 | 966 | // Find the closest common parent of the from/to states, if any. 967 | if (!isUpdate) { 968 | for (var i = 0; i < fromState.parents.length; i++) { 969 | var parent = fromState.parents[i]; 970 | 971 | if (toState.parents.indexOf(parent) > -1) { 972 | closestCommonParent = parent; 973 | break; 974 | } 975 | } 976 | } 977 | 978 | // Find the top-most parent owning some updated param(s) or bail if we first reach the closestCommonParent 979 | for (var _i = 0; _i < parents.length; _i++) { 980 | var _parent = parents[_i]; 981 | 982 | for (var param in paramsDiff.all) { 983 | if (_parent.params[param] || _parent.queryParams[param]) return { root: _parent, inclusive: true }; 984 | } 985 | 986 | if (_parent === closestCommonParent) return { root: closestCommonParent, inclusive: false }; 987 | } 988 | 989 | return closestCommonParent ? { root: closestCommonParent, inclusive: false } : { inclusive: true }; 990 | } 991 | 992 | function transitionStates(state, _ref) { 993 | var root = _ref.root, 994 | inclusive = _ref.inclusive; 995 | 996 | root = root || state.root; 997 | 998 | var p = state.parents; 999 | var end = Math.min(p.length, p.indexOf(root) + (inclusive ? 1 : 0)); 1000 | 1001 | return [state].concat(p.slice(0, end)); 1002 | } 1003 | 1004 | exports.default = Transition; 1005 | 1006 | },{}],5:[function(require,module,exports){ 1007 | 'use strict'; 1008 | 1009 | Object.defineProperty(exports, "__esModule", { 1010 | value: true 1011 | }); 1012 | exports.default = interceptAnchors; 1013 | 1014 | var router = void 0; 1015 | 1016 | function onMouseDown(evt) { 1017 | var href = hrefForEvent(evt); 1018 | 1019 | if (href !== undefined) router.transitionTo(href); 1020 | } 1021 | 1022 | function onMouseClick(evt) { 1023 | var href = hrefForEvent(evt); 1024 | 1025 | if (href !== undefined) { 1026 | evt.preventDefault(); 1027 | router.transitionTo(href); 1028 | } 1029 | } 1030 | 1031 | function hrefForEvent(evt) { 1032 | if (evt.defaultPrevented || evt.metaKey || evt.ctrlKey || !isLeftButton(evt)) return; 1033 | 1034 | var target = evt.target; 1035 | var anchor = anchorTarget(target); 1036 | if (!anchor) return; 1037 | 1038 | var dataNav = anchor.getAttribute('data-nav'); 1039 | 1040 | if (dataNav == 'ignore') return; 1041 | if (evt.type == 'mousedown' && dataNav != 'mousedown') return; 1042 | 1043 | var href = anchor.getAttribute('href'); 1044 | 1045 | if (!href) return; 1046 | if (href.charAt(0) == '#') { 1047 | if (router.options.urlSync != 'hash') return; 1048 | href = href.slice(1); 1049 | } 1050 | if (anchor.getAttribute('target') == '_blank') return; 1051 | if (!isLocalLink(anchor)) return; 1052 | 1053 | // At this point, we have a valid href to follow. 1054 | // Did the navigation already occur on mousedown though? 1055 | if (evt.type == 'click' && dataNav == 'mousedown') { 1056 | evt.preventDefault(); 1057 | return; 1058 | } 1059 | 1060 | return href; 1061 | } 1062 | 1063 | function isLeftButton(evt) { 1064 | return evt.which == 1; 1065 | } 1066 | 1067 | function anchorTarget(target) { 1068 | while (target) { 1069 | if (target.nodeName == 'A') return target; 1070 | target = target.parentNode; 1071 | } 1072 | } 1073 | 1074 | function isLocalLink(anchor) { 1075 | var hostname = anchor.hostname; 1076 | var port = anchor.port; 1077 | var protocol = anchor.protocol; 1078 | 1079 | // IE10 can lose the hostname/port property when setting a relative href from JS 1080 | if (!hostname) { 1081 | var tempAnchor = document.createElement("a"); 1082 | tempAnchor.href = anchor.href; 1083 | hostname = tempAnchor.hostname; 1084 | port = tempAnchor.port; 1085 | protocol = tempAnchor.protocol; 1086 | } 1087 | 1088 | var defaultPort = protocol.split(':')[0] === 'https' ? '443' : '80'; 1089 | 1090 | var sameHostname = hostname == location.hostname; 1091 | var samePort = (port || defaultPort) == (location.port || defaultPort); 1092 | 1093 | return sameHostname && samePort; 1094 | } 1095 | 1096 | function interceptAnchors(forRouter) { 1097 | router = forRouter; 1098 | 1099 | document.addEventListener('mousedown', onMouseDown); 1100 | document.addEventListener('click', onMouseClick); 1101 | } 1102 | 1103 | },{}],6:[function(require,module,exports){ 1104 | "use strict"; 1105 | 1106 | Object.defineProperty(exports, "__esModule", { 1107 | value: true 1108 | }); 1109 | 1110 | /* Represents the public API of the last instanciated router; Useful to break circular dependencies between router and its states */ 1111 | var api = {}; 1112 | exports.default = api; 1113 | 1114 | },{}],7:[function(require,module,exports){ 1115 | 'use strict'; 1116 | 1117 | Object.defineProperty(exports, "__esModule", { 1118 | value: true 1119 | }); 1120 | exports.util = exports.State = exports.api = exports.Router = undefined; 1121 | 1122 | var _util = require('./util'); 1123 | 1124 | var util = _interopRequireWildcard(_util); 1125 | 1126 | var _Router = require('./Router'); 1127 | 1128 | var _Router2 = _interopRequireDefault(_Router); 1129 | 1130 | var _api = require('./api'); 1131 | 1132 | var _api2 = _interopRequireDefault(_api); 1133 | 1134 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 1135 | 1136 | function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } 1137 | 1138 | var State = util.stateShorthand; 1139 | 1140 | exports.Router = _Router2.default; 1141 | exports.api = _api2.default; 1142 | exports.State = State; 1143 | exports.util = util; 1144 | 1145 | },{"./Router":1,"./api":6,"./util":8}],8:[function(require,module,exports){ 1146 | 'use strict'; 1147 | 1148 | Object.defineProperty(exports, "__esModule", { 1149 | value: true 1150 | }); 1151 | 1152 | var _slicedToArray = function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; }(); 1153 | 1154 | exports.noop = noop; 1155 | exports.arrayToObject = arrayToObject; 1156 | exports.objectToArray = objectToArray; 1157 | exports.copyObject = copyObject; 1158 | exports.mergeObjects = mergeObjects; 1159 | exports.mapValues = mapValues; 1160 | exports.objectDiff = objectDiff; 1161 | exports.makeMessage = makeMessage; 1162 | exports.parsePaths = parsePaths; 1163 | exports.parseQueryParams = parseQueryParams; 1164 | exports.normalizePathQuery = normalizePathQuery; 1165 | exports.stateShorthand = stateShorthand; 1166 | function noop() {} 1167 | 1168 | function arrayToObject(array) { 1169 | return array.reduce(function (obj, item) { 1170 | obj[item] = 1; 1171 | return obj; 1172 | }, {}); 1173 | } 1174 | 1175 | function objectToArray(obj) { 1176 | var array = []; 1177 | for (var key in obj) { 1178 | array.push(obj[key]); 1179 | }return array; 1180 | } 1181 | 1182 | function copyObject(obj) { 1183 | var copy = {}; 1184 | for (var key in obj) { 1185 | copy[key] = obj[key]; 1186 | }return copy; 1187 | } 1188 | 1189 | function mergeObjects(to, from) { 1190 | for (var key in from) { 1191 | to[key] = from[key]; 1192 | }return to; 1193 | } 1194 | 1195 | function mapValues(obj, fn) { 1196 | var result = {}; 1197 | for (var key in obj) { 1198 | result[key] = fn(obj[key]); 1199 | }return result; 1200 | } 1201 | 1202 | /* 1203 | * Return the set of all the keys that changed (either added, removed or modified). 1204 | */ 1205 | function objectDiff(obj1, obj2) { 1206 | var update = {}; 1207 | var enter = {}; 1208 | var exit = {}; 1209 | var all = {}; 1210 | 1211 | obj1 = obj1 || {}; 1212 | 1213 | for (var name in obj1) { 1214 | if (!(name in obj2)) exit[name] = all[name] = true;else if (obj1[name] != obj2[name]) update[name] = all[name] = true; 1215 | } 1216 | 1217 | for (var _name in obj2) { 1218 | if (!(_name in obj1)) enter[_name] = all[_name] = true; 1219 | } 1220 | 1221 | return { all: all, update: update, enter: enter, exit: exit }; 1222 | } 1223 | 1224 | function makeMessage() { 1225 | var message = arguments[0]; 1226 | var tokens = Array.prototype.slice.call(arguments, 1); 1227 | 1228 | for (var i = 0, l = tokens.length; i < l; i++) { 1229 | message = message.replace('{' + i + '}', tokens[i]); 1230 | }return message; 1231 | } 1232 | 1233 | function parsePaths(path) { 1234 | return path.split('/').filter(function (str) { 1235 | return str.length; 1236 | }).map(function (str) { 1237 | return decodeURIComponent(str); 1238 | }); 1239 | } 1240 | 1241 | function parseQueryParams(query) { 1242 | return query ? query.split('&').reduce(function (res, paramValue) { 1243 | var _paramValue$split = paramValue.split('='), 1244 | _paramValue$split2 = _slicedToArray(_paramValue$split, 2), 1245 | param = _paramValue$split2[0], 1246 | value = _paramValue$split2[1]; 1247 | 1248 | res[param] = decodeURIComponent(value); 1249 | return res; 1250 | }, {}) : {}; 1251 | } 1252 | 1253 | var LEADING_SLASHES = /^\/+/; 1254 | var TRAILING_SLASHES = /^([^?]*?)\/+$/; 1255 | var TRAILING_SLASHES_BEFORE_QUERY = /\/+\?/; 1256 | function normalizePathQuery(pathQuery) { 1257 | return '/' + pathQuery.replace(LEADING_SLASHES, '').replace(TRAILING_SLASHES, '$1').replace(TRAILING_SLASHES_BEFORE_QUERY, '?'); 1258 | } 1259 | 1260 | function stateShorthand(uri, options, children) { 1261 | return mergeObjects({ uri: uri, children: children || {} }, options); 1262 | } 1263 | 1264 | },{}]},{},[7])(7) 1265 | }); -------------------------------------------------------------------------------- /test/lib/qunit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * QUnit v1.9.0 - A JavaScript Unit Testing Framework 3 | * 4 | * http://docs.jquery.com/QUnit 5 | * 6 | * Copyright (c) 2012 John Resig, Jörn Zaefferer 7 | * Dual licensed under the MIT (MIT-LICENSE.txt) 8 | * or GPL (GPL-LICENSE.txt) licenses. 9 | */ 10 | 11 | (function( window ) { 12 | 13 | var QUnit, 14 | config, 15 | onErrorFnPrev, 16 | testId = 0, 17 | fileName = (sourceFromStacktrace( 0 ) || "" ).replace(/(:\d+)+\)?/, "").replace(/.+\//, ""), 18 | toString = Object.prototype.toString, 19 | hasOwn = Object.prototype.hasOwnProperty, 20 | defined = { 21 | setTimeout: typeof window.setTimeout !== "undefined", 22 | sessionStorage: (function() { 23 | var x = "qunit-test-string"; 24 | try { 25 | sessionStorage.setItem( x, x ); 26 | sessionStorage.removeItem( x ); 27 | return true; 28 | } catch( e ) { 29 | return false; 30 | } 31 | }()) 32 | }; 33 | 34 | function Test( settings ) { 35 | extend( this, settings ); 36 | this.assertions = []; 37 | this.testNumber = ++Test.count; 38 | } 39 | 40 | Test.count = 0; 41 | 42 | Test.prototype = { 43 | init: function() { 44 | var a, b, li, 45 | tests = id( "qunit-tests" ); 46 | 47 | if ( tests ) { 48 | b = document.createElement( "strong" ); 49 | b.innerHTML = this.name; 50 | 51 | // `a` initialized at top of scope 52 | a = document.createElement( "a" ); 53 | a.innerHTML = "Rerun"; 54 | a.href = QUnit.url({ testNumber: this.testNumber }); 55 | 56 | li = document.createElement( "li" ); 57 | li.appendChild( b ); 58 | li.appendChild( a ); 59 | li.className = "running"; 60 | li.id = this.id = "qunit-test-output" + testId++; 61 | 62 | tests.appendChild( li ); 63 | } 64 | }, 65 | setup: function() { 66 | if ( this.module !== config.previousModule ) { 67 | if ( config.previousModule ) { 68 | runLoggingCallbacks( "moduleDone", QUnit, { 69 | name: config.previousModule, 70 | failed: config.moduleStats.bad, 71 | passed: config.moduleStats.all - config.moduleStats.bad, 72 | total: config.moduleStats.all 73 | }); 74 | } 75 | config.previousModule = this.module; 76 | config.moduleStats = { all: 0, bad: 0 }; 77 | runLoggingCallbacks( "moduleStart", QUnit, { 78 | name: this.module 79 | }); 80 | } else if ( config.autorun ) { 81 | runLoggingCallbacks( "moduleStart", QUnit, { 82 | name: this.module 83 | }); 84 | } 85 | 86 | config.current = this; 87 | 88 | this.testEnvironment = extend({ 89 | setup: function() {}, 90 | teardown: function() {} 91 | }, this.moduleTestEnvironment ); 92 | 93 | runLoggingCallbacks( "testStart", QUnit, { 94 | name: this.testName, 95 | module: this.module 96 | }); 97 | 98 | // allow utility functions to access the current test environment 99 | // TODO why?? 100 | QUnit.current_testEnvironment = this.testEnvironment; 101 | 102 | if ( !config.pollution ) { 103 | saveGlobal(); 104 | } 105 | if ( config.notrycatch ) { 106 | this.testEnvironment.setup.call( this.testEnvironment ); 107 | return; 108 | } 109 | try { 110 | this.testEnvironment.setup.call( this.testEnvironment ); 111 | } catch( e ) { 112 | QUnit.pushFailure( "Setup failed on " + this.testName + ": " + e.message, extractStacktrace( e, 1 ) ); 113 | } 114 | }, 115 | run: function() { 116 | config.current = this; 117 | 118 | var running = id( "qunit-testresult" ); 119 | 120 | if ( running ) { 121 | running.innerHTML = "Running:
" + this.name; 122 | } 123 | 124 | if ( this.async ) { 125 | QUnit.stop(); 126 | } 127 | 128 | if ( config.notrycatch ) { 129 | this.callback.call( this.testEnvironment, QUnit.assert ); 130 | return; 131 | } 132 | 133 | try { 134 | this.callback.call( this.testEnvironment, QUnit.assert ); 135 | } catch( e ) { 136 | QUnit.pushFailure( "Died on test #" + (this.assertions.length + 1) + " " + this.stack + ": " + e.message, extractStacktrace( e, 0 ) ); 137 | // else next test will carry the responsibility 138 | saveGlobal(); 139 | 140 | // Restart the tests if they're blocking 141 | if ( config.blocking ) { 142 | QUnit.start(); 143 | } 144 | } 145 | }, 146 | teardown: function() { 147 | config.current = this; 148 | if ( config.notrycatch ) { 149 | this.testEnvironment.teardown.call( this.testEnvironment ); 150 | return; 151 | } else { 152 | try { 153 | this.testEnvironment.teardown.call( this.testEnvironment ); 154 | } catch( e ) { 155 | QUnit.pushFailure( "Teardown failed on " + this.testName + ": " + e.message, extractStacktrace( e, 1 ) ); 156 | } 157 | } 158 | checkPollution(); 159 | }, 160 | finish: function() { 161 | config.current = this; 162 | if ( config.requireExpects && this.expected == null ) { 163 | QUnit.pushFailure( "Expected number of assertions to be defined, but expect() was not called.", this.stack ); 164 | } else if ( this.expected != null && this.expected != this.assertions.length ) { 165 | QUnit.pushFailure( "Expected " + this.expected + " assertions, but " + this.assertions.length + " were run", this.stack ); 166 | } else if ( this.expected == null && !this.assertions.length ) { 167 | QUnit.pushFailure( "Expected at least one assertion, but none were run - call expect(0) to accept zero assertions.", this.stack ); 168 | } 169 | 170 | var assertion, a, b, i, li, ol, 171 | test = this, 172 | good = 0, 173 | bad = 0, 174 | tests = id( "qunit-tests" ); 175 | 176 | config.stats.all += this.assertions.length; 177 | config.moduleStats.all += this.assertions.length; 178 | 179 | if ( tests ) { 180 | ol = document.createElement( "ol" ); 181 | 182 | for ( i = 0; i < this.assertions.length; i++ ) { 183 | assertion = this.assertions[i]; 184 | 185 | li = document.createElement( "li" ); 186 | li.className = assertion.result ? "pass" : "fail"; 187 | li.innerHTML = assertion.message || ( assertion.result ? "okay" : "failed" ); 188 | ol.appendChild( li ); 189 | 190 | if ( assertion.result ) { 191 | good++; 192 | } else { 193 | bad++; 194 | config.stats.bad++; 195 | config.moduleStats.bad++; 196 | } 197 | } 198 | 199 | // store result when possible 200 | if ( QUnit.config.reorder && defined.sessionStorage ) { 201 | if ( bad ) { 202 | sessionStorage.setItem( "qunit-test-" + this.module + "-" + this.testName, bad ); 203 | } else { 204 | sessionStorage.removeItem( "qunit-test-" + this.module + "-" + this.testName ); 205 | } 206 | } 207 | 208 | if ( bad === 0 ) { 209 | ol.style.display = "none"; 210 | } 211 | 212 | // `b` initialized at top of scope 213 | b = document.createElement( "strong" ); 214 | b.innerHTML = this.name + " (" + bad + ", " + good + ", " + this.assertions.length + ")"; 215 | 216 | addEvent(b, "click", function() { 217 | var next = b.nextSibling.nextSibling, 218 | display = next.style.display; 219 | next.style.display = display === "none" ? "block" : "none"; 220 | }); 221 | 222 | addEvent(b, "dblclick", function( e ) { 223 | var target = e && e.target ? e.target : window.event.srcElement; 224 | if ( target.nodeName.toLowerCase() == "span" || target.nodeName.toLowerCase() == "b" ) { 225 | target = target.parentNode; 226 | } 227 | if ( window.location && target.nodeName.toLowerCase() === "strong" ) { 228 | window.location = QUnit.url({ testNumber: test.testNumber }); 229 | } 230 | }); 231 | 232 | // `li` initialized at top of scope 233 | li = id( this.id ); 234 | li.className = bad ? "fail" : "pass"; 235 | li.removeChild( li.firstChild ); 236 | a = li.firstChild; 237 | li.appendChild( b ); 238 | li.appendChild ( a ); 239 | li.appendChild( ol ); 240 | 241 | } else { 242 | for ( i = 0; i < this.assertions.length; i++ ) { 243 | if ( !this.assertions[i].result ) { 244 | bad++; 245 | config.stats.bad++; 246 | config.moduleStats.bad++; 247 | } 248 | } 249 | } 250 | 251 | runLoggingCallbacks( "testDone", QUnit, { 252 | name: this.testName, 253 | module: this.module, 254 | failed: bad, 255 | passed: this.assertions.length - bad, 256 | total: this.assertions.length 257 | }); 258 | 259 | QUnit.reset(); 260 | 261 | config.current = undefined; 262 | }, 263 | 264 | queue: function() { 265 | var bad, 266 | test = this; 267 | 268 | synchronize(function() { 269 | test.init(); 270 | }); 271 | function run() { 272 | // each of these can by async 273 | synchronize(function() { 274 | test.setup(); 275 | }); 276 | synchronize(function() { 277 | test.run(); 278 | }); 279 | synchronize(function() { 280 | test.teardown(); 281 | }); 282 | synchronize(function() { 283 | test.finish(); 284 | }); 285 | } 286 | 287 | // `bad` initialized at top of scope 288 | // defer when previous test run passed, if storage is available 289 | bad = QUnit.config.reorder && defined.sessionStorage && 290 | +sessionStorage.getItem( "qunit-test-" + this.module + "-" + this.testName ); 291 | 292 | if ( bad ) { 293 | run(); 294 | } else { 295 | synchronize( run, true ); 296 | } 297 | } 298 | }; 299 | 300 | // Root QUnit object. 301 | // `QUnit` initialized at top of scope 302 | QUnit = { 303 | 304 | // call on start of module test to prepend name to all tests 305 | module: function( name, testEnvironment ) { 306 | config.currentModule = name; 307 | config.currentModuleTestEnviroment = testEnvironment; 308 | }, 309 | 310 | asyncTest: function( testName, expected, callback ) { 311 | if ( arguments.length === 2 ) { 312 | callback = expected; 313 | expected = null; 314 | } 315 | 316 | QUnit.test( testName, expected, callback, true ); 317 | }, 318 | 319 | test: function( testName, expected, callback, async ) { 320 | var test, 321 | name = "" + escapeInnerText( testName ) + ""; 322 | 323 | if ( arguments.length === 2 ) { 324 | callback = expected; 325 | expected = null; 326 | } 327 | 328 | if ( config.currentModule ) { 329 | name = "" + config.currentModule + ": " + name; 330 | } 331 | 332 | test = new Test({ 333 | name: name, 334 | testName: testName, 335 | expected: expected, 336 | async: async, 337 | callback: callback, 338 | module: config.currentModule, 339 | moduleTestEnvironment: config.currentModuleTestEnviroment, 340 | stack: sourceFromStacktrace( 2 ) 341 | }); 342 | 343 | if ( !validTest( test ) ) { 344 | return; 345 | } 346 | 347 | test.queue(); 348 | }, 349 | 350 | // Specify the number of expected assertions to gurantee that failed test (no assertions are run at all) don't slip through. 351 | expect: function( asserts ) { 352 | config.current.expected = asserts; 353 | }, 354 | 355 | start: function( count ) { 356 | config.semaphore -= count || 1; 357 | // don't start until equal number of stop-calls 358 | if ( config.semaphore > 0 ) { 359 | return; 360 | } 361 | // ignore if start is called more often then stop 362 | if ( config.semaphore < 0 ) { 363 | config.semaphore = 0; 364 | } 365 | // A slight delay, to avoid any current callbacks 366 | if ( defined.setTimeout ) { 367 | window.setTimeout(function() { 368 | if ( config.semaphore > 0 ) { 369 | return; 370 | } 371 | if ( config.timeout ) { 372 | clearTimeout( config.timeout ); 373 | } 374 | 375 | config.blocking = false; 376 | process( true ); 377 | }, 13); 378 | } else { 379 | config.blocking = false; 380 | process( true ); 381 | } 382 | }, 383 | 384 | stop: function( count ) { 385 | config.semaphore += count || 1; 386 | config.blocking = true; 387 | 388 | if ( config.testTimeout && defined.setTimeout ) { 389 | clearTimeout( config.timeout ); 390 | config.timeout = window.setTimeout(function() { 391 | QUnit.ok( false, "Test timed out" ); 392 | config.semaphore = 1; 393 | QUnit.start(); 394 | }, config.testTimeout ); 395 | } 396 | } 397 | }; 398 | 399 | // Asssert helpers 400 | // All of these must call either QUnit.push() or manually do: 401 | // - runLoggingCallbacks( "log", .. ); 402 | // - config.current.assertions.push({ .. }); 403 | QUnit.assert = { 404 | /** 405 | * Asserts rough true-ish result. 406 | * @name ok 407 | * @function 408 | * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" ); 409 | */ 410 | ok: function( result, msg ) { 411 | if ( !config.current ) { 412 | throw new Error( "ok() assertion outside test context, was " + sourceFromStacktrace(2) ); 413 | } 414 | result = !!result; 415 | 416 | var source, 417 | details = { 418 | result: result, 419 | message: msg 420 | }; 421 | 422 | msg = escapeInnerText( msg || (result ? "okay" : "failed" ) ); 423 | msg = "" + msg + ""; 424 | 425 | if ( !result ) { 426 | source = sourceFromStacktrace( 2 ); 427 | if ( source ) { 428 | details.source = source; 429 | msg += "
Source:
" + escapeInnerText( source ) + "
"; 430 | } 431 | } 432 | runLoggingCallbacks( "log", QUnit, details ); 433 | config.current.assertions.push({ 434 | result: result, 435 | message: msg 436 | }); 437 | }, 438 | 439 | /** 440 | * Assert that the first two arguments are equal, with an optional message. 441 | * Prints out both actual and expected values. 442 | * @name equal 443 | * @function 444 | * @example equal( format( "Received {0} bytes.", 2), "Received 2 bytes.", "format() replaces {0} with next argument" ); 445 | */ 446 | equal: function( actual, expected, message ) { 447 | QUnit.push( expected == actual, actual, expected, message ); 448 | }, 449 | 450 | /** 451 | * @name notEqual 452 | * @function 453 | */ 454 | notEqual: function( actual, expected, message ) { 455 | QUnit.push( expected != actual, actual, expected, message ); 456 | }, 457 | 458 | /** 459 | * @name deepEqual 460 | * @function 461 | */ 462 | deepEqual: function( actual, expected, message ) { 463 | QUnit.push( QUnit.equiv(actual, expected), actual, expected, message ); 464 | }, 465 | 466 | /** 467 | * @name notDeepEqual 468 | * @function 469 | */ 470 | notDeepEqual: function( actual, expected, message ) { 471 | QUnit.push( !QUnit.equiv(actual, expected), actual, expected, message ); 472 | }, 473 | 474 | /** 475 | * @name strictEqual 476 | * @function 477 | */ 478 | strictEqual: function( actual, expected, message ) { 479 | QUnit.push( expected === actual, actual, expected, message ); 480 | }, 481 | 482 | /** 483 | * @name notStrictEqual 484 | * @function 485 | */ 486 | notStrictEqual: function( actual, expected, message ) { 487 | QUnit.push( expected !== actual, actual, expected, message ); 488 | }, 489 | 490 | throws: function( block, expected, message ) { 491 | var actual, 492 | ok = false; 493 | 494 | // 'expected' is optional 495 | if ( typeof expected === "string" ) { 496 | message = expected; 497 | expected = null; 498 | } 499 | 500 | config.current.ignoreGlobalErrors = true; 501 | try { 502 | block.call( config.current.testEnvironment ); 503 | } catch (e) { 504 | actual = e; 505 | } 506 | config.current.ignoreGlobalErrors = false; 507 | 508 | if ( actual ) { 509 | // we don't want to validate thrown error 510 | if ( !expected ) { 511 | ok = true; 512 | // expected is a regexp 513 | } else if ( QUnit.objectType( expected ) === "regexp" ) { 514 | ok = expected.test( actual ); 515 | // expected is a constructor 516 | } else if ( actual instanceof expected ) { 517 | ok = true; 518 | // expected is a validation function which returns true is validation passed 519 | } else if ( expected.call( {}, actual ) === true ) { 520 | ok = true; 521 | } 522 | 523 | QUnit.push( ok, actual, null, message ); 524 | } else { 525 | QUnit.pushFailure( message, null, 'No exception was thrown.' ); 526 | } 527 | } 528 | }; 529 | 530 | /** 531 | * @deprecate since 1.8.0 532 | * Kept assertion helpers in root for backwards compatibility 533 | */ 534 | extend( QUnit, QUnit.assert ); 535 | 536 | /** 537 | * @deprecated since 1.9.0 538 | * Kept global "raises()" for backwards compatibility 539 | */ 540 | QUnit.raises = QUnit.assert.throws; 541 | 542 | /** 543 | * @deprecated since 1.0.0, replaced with error pushes since 1.3.0 544 | * Kept to avoid TypeErrors for undefined methods. 545 | */ 546 | QUnit.equals = function() { 547 | QUnit.push( false, false, false, "QUnit.equals has been deprecated since 2009 (e88049a0), use QUnit.equal instead" ); 548 | }; 549 | QUnit.same = function() { 550 | QUnit.push( false, false, false, "QUnit.same has been deprecated since 2009 (e88049a0), use QUnit.deepEqual instead" ); 551 | }; 552 | 553 | // We want access to the constructor's prototype 554 | (function() { 555 | function F() {} 556 | F.prototype = QUnit; 557 | QUnit = new F(); 558 | // Make F QUnit's constructor so that we can add to the prototype later 559 | QUnit.constructor = F; 560 | }()); 561 | 562 | /** 563 | * Config object: Maintain internal state 564 | * Later exposed as QUnit.config 565 | * `config` initialized at top of scope 566 | */ 567 | config = { 568 | // The queue of tests to run 569 | queue: [], 570 | 571 | // block until document ready 572 | blocking: true, 573 | 574 | // when enabled, show only failing tests 575 | // gets persisted through sessionStorage and can be changed in UI via checkbox 576 | hidepassed: false, 577 | 578 | // by default, run previously failed tests first 579 | // very useful in combination with "Hide passed tests" checked 580 | reorder: true, 581 | 582 | // by default, modify document.title when suite is done 583 | altertitle: true, 584 | 585 | // when enabled, all tests must call expect() 586 | requireExpects: false, 587 | 588 | // add checkboxes that are persisted in the query-string 589 | // when enabled, the id is set to `true` as a `QUnit.config` property 590 | urlConfig: [ 591 | { 592 | id: "noglobals", 593 | label: "Check for Globals", 594 | tooltip: "Enabling this will test if any test introduces new properties on the `window` object. Stored as query-strings." 595 | }, 596 | { 597 | id: "notrycatch", 598 | label: "No try-catch", 599 | tooltip: "Enabling this will run tests outside of a try-catch block. Makes debugging exceptions in IE reasonable. Stored as query-strings." 600 | } 601 | ], 602 | 603 | // logging callback queues 604 | begin: [], 605 | done: [], 606 | log: [], 607 | testStart: [], 608 | testDone: [], 609 | moduleStart: [], 610 | moduleDone: [] 611 | }; 612 | 613 | // Initialize more QUnit.config and QUnit.urlParams 614 | (function() { 615 | var i, 616 | location = window.location || { search: "", protocol: "file:" }, 617 | params = location.search.slice( 1 ).split( "&" ), 618 | length = params.length, 619 | urlParams = {}, 620 | current; 621 | 622 | if ( params[ 0 ] ) { 623 | for ( i = 0; i < length; i++ ) { 624 | current = params[ i ].split( "=" ); 625 | current[ 0 ] = decodeURIComponent( current[ 0 ] ); 626 | // allow just a key to turn on a flag, e.g., test.html?noglobals 627 | current[ 1 ] = current[ 1 ] ? decodeURIComponent( current[ 1 ] ) : true; 628 | urlParams[ current[ 0 ] ] = current[ 1 ]; 629 | } 630 | } 631 | 632 | QUnit.urlParams = urlParams; 633 | 634 | // String search anywhere in moduleName+testName 635 | config.filter = urlParams.filter; 636 | 637 | // Exact match of the module name 638 | config.module = urlParams.module; 639 | 640 | config.testNumber = parseInt( urlParams.testNumber, 10 ) || null; 641 | 642 | // Figure out if we're running the tests from a server or not 643 | QUnit.isLocal = location.protocol === "file:"; 644 | }()); 645 | 646 | // Export global variables, unless an 'exports' object exists, 647 | // in that case we assume we're in CommonJS (dealt with on the bottom of the script) 648 | if ( typeof exports === "undefined" ) { 649 | extend( window, QUnit ); 650 | 651 | // Expose QUnit object 652 | window.QUnit = QUnit; 653 | } 654 | 655 | // Extend QUnit object, 656 | // these after set here because they should not be exposed as global functions 657 | extend( QUnit, { 658 | config: config, 659 | 660 | // Initialize the configuration options 661 | init: function() { 662 | extend( config, { 663 | stats: { all: 0, bad: 0 }, 664 | moduleStats: { all: 0, bad: 0 }, 665 | started: +new Date(), 666 | updateRate: 1000, 667 | blocking: false, 668 | autostart: true, 669 | autorun: false, 670 | filter: "", 671 | queue: [], 672 | semaphore: 0 673 | }); 674 | 675 | var tests, banner, result, 676 | qunit = id( "qunit" ); 677 | 678 | if ( qunit ) { 679 | qunit.innerHTML = 680 | "

" + escapeInnerText( document.title ) + "

" + 681 | "

" + 682 | "
" + 683 | "

" + 684 | "
    "; 685 | } 686 | 687 | tests = id( "qunit-tests" ); 688 | banner = id( "qunit-banner" ); 689 | result = id( "qunit-testresult" ); 690 | 691 | if ( tests ) { 692 | tests.innerHTML = ""; 693 | } 694 | 695 | if ( banner ) { 696 | banner.className = ""; 697 | } 698 | 699 | if ( result ) { 700 | result.parentNode.removeChild( result ); 701 | } 702 | 703 | if ( tests ) { 704 | result = document.createElement( "p" ); 705 | result.id = "qunit-testresult"; 706 | result.className = "result"; 707 | tests.parentNode.insertBefore( result, tests ); 708 | result.innerHTML = "Running...
     "; 709 | } 710 | }, 711 | 712 | // Resets the test setup. Useful for tests that modify the DOM. 713 | // If jQuery is available, uses jQuery's html(), otherwise just innerHTML. 714 | reset: function() { 715 | var fixture; 716 | 717 | if ( window.jQuery ) { 718 | jQuery( "#qunit-fixture" ).html( config.fixture ); 719 | } else { 720 | fixture = id( "qunit-fixture" ); 721 | if ( fixture ) { 722 | fixture.innerHTML = config.fixture; 723 | } 724 | } 725 | }, 726 | 727 | // Trigger an event on an element. 728 | // @example triggerEvent( document.body, "click" ); 729 | triggerEvent: function( elem, type, event ) { 730 | if ( document.createEvent ) { 731 | event = document.createEvent( "MouseEvents" ); 732 | event.initMouseEvent(type, true, true, elem.ownerDocument.defaultView, 733 | 0, 0, 0, 0, 0, false, false, false, false, 0, null); 734 | 735 | elem.dispatchEvent( event ); 736 | } else if ( elem.fireEvent ) { 737 | elem.fireEvent( "on" + type ); 738 | } 739 | }, 740 | 741 | // Safe object type checking 742 | is: function( type, obj ) { 743 | return QUnit.objectType( obj ) == type; 744 | }, 745 | 746 | objectType: function( obj ) { 747 | if ( typeof obj === "undefined" ) { 748 | return "undefined"; 749 | // consider: typeof null === object 750 | } 751 | if ( obj === null ) { 752 | return "null"; 753 | } 754 | 755 | var type = toString.call( obj ).match(/^\[object\s(.*)\]$/)[1] || ""; 756 | 757 | switch ( type ) { 758 | case "Number": 759 | if ( isNaN(obj) ) { 760 | return "nan"; 761 | } 762 | return "number"; 763 | case "String": 764 | case "Boolean": 765 | case "Array": 766 | case "Date": 767 | case "RegExp": 768 | case "Function": 769 | return type.toLowerCase(); 770 | } 771 | if ( typeof obj === "object" ) { 772 | return "object"; 773 | } 774 | return undefined; 775 | }, 776 | 777 | push: function( result, actual, expected, message ) { 778 | if ( !config.current ) { 779 | throw new Error( "assertion outside test context, was " + sourceFromStacktrace() ); 780 | } 781 | 782 | var output, source, 783 | details = { 784 | result: result, 785 | message: message, 786 | actual: actual, 787 | expected: expected 788 | }; 789 | 790 | message = escapeInnerText( message ) || ( result ? "okay" : "failed" ); 791 | message = "" + message + ""; 792 | output = message; 793 | 794 | if ( !result ) { 795 | expected = escapeInnerText( QUnit.jsDump.parse(expected) ); 796 | actual = escapeInnerText( QUnit.jsDump.parse(actual) ); 797 | output += ""; 798 | 799 | if ( actual != expected ) { 800 | output += ""; 801 | output += ""; 802 | } 803 | 804 | source = sourceFromStacktrace(); 805 | 806 | if ( source ) { 807 | details.source = source; 808 | output += ""; 809 | } 810 | 811 | output += "
    Expected:
    " + expected + "
    Result:
    " + actual + "
    Diff:
    " + QUnit.diff( expected, actual ) + "
    Source:
    " + escapeInnerText( source ) + "
    "; 812 | } 813 | 814 | runLoggingCallbacks( "log", QUnit, details ); 815 | 816 | config.current.assertions.push({ 817 | result: !!result, 818 | message: output 819 | }); 820 | }, 821 | 822 | pushFailure: function( message, source, actual ) { 823 | if ( !config.current ) { 824 | throw new Error( "pushFailure() assertion outside test context, was " + sourceFromStacktrace(2) ); 825 | } 826 | 827 | var output, 828 | details = { 829 | result: false, 830 | message: message 831 | }; 832 | 833 | message = escapeInnerText( message ) || "error"; 834 | message = "" + message + ""; 835 | output = message; 836 | 837 | output += ""; 838 | 839 | if ( actual ) { 840 | output += ""; 841 | } 842 | 843 | if ( source ) { 844 | details.source = source; 845 | output += ""; 846 | } 847 | 848 | output += "
    Result:
    " + escapeInnerText( actual ) + "
    Source:
    " + escapeInnerText( source ) + "
    "; 849 | 850 | runLoggingCallbacks( "log", QUnit, details ); 851 | 852 | config.current.assertions.push({ 853 | result: false, 854 | message: output 855 | }); 856 | }, 857 | 858 | url: function( params ) { 859 | params = extend( extend( {}, QUnit.urlParams ), params ); 860 | var key, 861 | querystring = "?"; 862 | 863 | for ( key in params ) { 864 | if ( !hasOwn.call( params, key ) ) { 865 | continue; 866 | } 867 | querystring += encodeURIComponent( key ) + "=" + 868 | encodeURIComponent( params[ key ] ) + "&"; 869 | } 870 | return window.location.pathname + querystring.slice( 0, -1 ); 871 | }, 872 | 873 | extend: extend, 874 | id: id, 875 | addEvent: addEvent 876 | // load, equiv, jsDump, diff: Attached later 877 | }); 878 | 879 | /** 880 | * @deprecated: Created for backwards compatibility with test runner that set the hook function 881 | * into QUnit.{hook}, instead of invoking it and passing the hook function. 882 | * QUnit.constructor is set to the empty F() above so that we can add to it's prototype here. 883 | * Doing this allows us to tell if the following methods have been overwritten on the actual 884 | * QUnit object. 885 | */ 886 | extend( QUnit.constructor.prototype, { 887 | 888 | // Logging callbacks; all receive a single argument with the listed properties 889 | // run test/logs.html for any related changes 890 | begin: registerLoggingCallback( "begin" ), 891 | 892 | // done: { failed, passed, total, runtime } 893 | done: registerLoggingCallback( "done" ), 894 | 895 | // log: { result, actual, expected, message } 896 | log: registerLoggingCallback( "log" ), 897 | 898 | // testStart: { name } 899 | testStart: registerLoggingCallback( "testStart" ), 900 | 901 | // testDone: { name, failed, passed, total } 902 | testDone: registerLoggingCallback( "testDone" ), 903 | 904 | // moduleStart: { name } 905 | moduleStart: registerLoggingCallback( "moduleStart" ), 906 | 907 | // moduleDone: { name, failed, passed, total } 908 | moduleDone: registerLoggingCallback( "moduleDone" ) 909 | }); 910 | 911 | if ( typeof document === "undefined" || document.readyState === "complete" ) { 912 | config.autorun = true; 913 | } 914 | 915 | QUnit.load = function() { 916 | runLoggingCallbacks( "begin", QUnit, {} ); 917 | 918 | // Initialize the config, saving the execution queue 919 | var banner, filter, i, label, len, main, ol, toolbar, userAgent, val, urlConfigCheckboxes, 920 | urlConfigHtml = "", 921 | oldconfig = extend( {}, config ); 922 | 923 | QUnit.init(); 924 | extend(config, oldconfig); 925 | 926 | config.blocking = false; 927 | 928 | len = config.urlConfig.length; 929 | 930 | for ( i = 0; i < len; i++ ) { 931 | val = config.urlConfig[i]; 932 | if ( typeof val === "string" ) { 933 | val = { 934 | id: val, 935 | label: val, 936 | tooltip: "[no tooltip available]" 937 | }; 938 | } 939 | config[ val.id ] = QUnit.urlParams[ val.id ]; 940 | urlConfigHtml += ""; 941 | } 942 | 943 | // `userAgent` initialized at top of scope 944 | userAgent = id( "qunit-userAgent" ); 945 | if ( userAgent ) { 946 | userAgent.innerHTML = navigator.userAgent; 947 | } 948 | 949 | // `banner` initialized at top of scope 950 | banner = id( "qunit-header" ); 951 | if ( banner ) { 952 | banner.innerHTML = "" + banner.innerHTML + " "; 953 | } 954 | 955 | // `toolbar` initialized at top of scope 956 | toolbar = id( "qunit-testrunner-toolbar" ); 957 | if ( toolbar ) { 958 | // `filter` initialized at top of scope 959 | filter = document.createElement( "input" ); 960 | filter.type = "checkbox"; 961 | filter.id = "qunit-filter-pass"; 962 | 963 | addEvent( filter, "click", function() { 964 | var tmp, 965 | ol = document.getElementById( "qunit-tests" ); 966 | 967 | if ( filter.checked ) { 968 | ol.className = ol.className + " hidepass"; 969 | } else { 970 | tmp = " " + ol.className.replace( /[\n\t\r]/g, " " ) + " "; 971 | ol.className = tmp.replace( / hidepass /, " " ); 972 | } 973 | if ( defined.sessionStorage ) { 974 | if (filter.checked) { 975 | sessionStorage.setItem( "qunit-filter-passed-tests", "true" ); 976 | } else { 977 | sessionStorage.removeItem( "qunit-filter-passed-tests" ); 978 | } 979 | } 980 | }); 981 | 982 | if ( config.hidepassed || defined.sessionStorage && sessionStorage.getItem( "qunit-filter-passed-tests" ) ) { 983 | filter.checked = true; 984 | // `ol` initialized at top of scope 985 | ol = document.getElementById( "qunit-tests" ); 986 | ol.className = ol.className + " hidepass"; 987 | } 988 | toolbar.appendChild( filter ); 989 | 990 | // `label` initialized at top of scope 991 | label = document.createElement( "label" ); 992 | label.setAttribute( "for", "qunit-filter-pass" ); 993 | label.setAttribute( "title", "Only show tests and assertons that fail. Stored in sessionStorage." ); 994 | label.innerHTML = "Hide passed tests"; 995 | toolbar.appendChild( label ); 996 | 997 | urlConfigCheckboxes = document.createElement( 'span' ); 998 | urlConfigCheckboxes.innerHTML = urlConfigHtml; 999 | addEvent( urlConfigCheckboxes, "change", function( event ) { 1000 | var params = {}; 1001 | params[ event.target.name ] = event.target.checked ? true : undefined; 1002 | window.location = QUnit.url( params ); 1003 | }); 1004 | toolbar.appendChild( urlConfigCheckboxes ); 1005 | } 1006 | 1007 | // `main` initialized at top of scope 1008 | main = id( "qunit-fixture" ); 1009 | if ( main ) { 1010 | config.fixture = main.innerHTML; 1011 | } 1012 | 1013 | if ( config.autostart ) { 1014 | QUnit.start(); 1015 | } 1016 | }; 1017 | 1018 | addEvent( window, "load", QUnit.load ); 1019 | 1020 | // `onErrorFnPrev` initialized at top of scope 1021 | // Preserve other handlers 1022 | onErrorFnPrev = window.onerror; 1023 | 1024 | // Cover uncaught exceptions 1025 | // Returning true will surpress the default browser handler, 1026 | // returning false will let it run. 1027 | window.onerror = function ( error, filePath, linerNr ) { 1028 | var ret = false; 1029 | if ( onErrorFnPrev ) { 1030 | ret = onErrorFnPrev( error, filePath, linerNr ); 1031 | } 1032 | 1033 | // Treat return value as window.onerror itself does, 1034 | // Only do our handling if not surpressed. 1035 | if ( ret !== true ) { 1036 | if ( QUnit.config.current ) { 1037 | if ( QUnit.config.current.ignoreGlobalErrors ) { 1038 | return true; 1039 | } 1040 | QUnit.pushFailure( error, filePath + ":" + linerNr ); 1041 | } else { 1042 | QUnit.test( "global failure", function() { 1043 | QUnit.pushFailure( error, filePath + ":" + linerNr ); 1044 | }); 1045 | } 1046 | return false; 1047 | } 1048 | 1049 | return ret; 1050 | }; 1051 | 1052 | function done() { 1053 | config.autorun = true; 1054 | 1055 | // Log the last module results 1056 | if ( config.currentModule ) { 1057 | runLoggingCallbacks( "moduleDone", QUnit, { 1058 | name: config.currentModule, 1059 | failed: config.moduleStats.bad, 1060 | passed: config.moduleStats.all - config.moduleStats.bad, 1061 | total: config.moduleStats.all 1062 | }); 1063 | } 1064 | 1065 | var i, key, 1066 | banner = id( "qunit-banner" ), 1067 | tests = id( "qunit-tests" ), 1068 | runtime = +new Date() - config.started, 1069 | passed = config.stats.all - config.stats.bad, 1070 | html = [ 1071 | "Tests completed in ", 1072 | runtime, 1073 | " milliseconds.
    ", 1074 | "", 1075 | passed, 1076 | " tests of ", 1077 | config.stats.all, 1078 | " passed, ", 1079 | config.stats.bad, 1080 | " failed." 1081 | ].join( "" ); 1082 | 1083 | if ( banner ) { 1084 | banner.className = ( config.stats.bad ? "qunit-fail" : "qunit-pass" ); 1085 | } 1086 | 1087 | if ( tests ) { 1088 | id( "qunit-testresult" ).innerHTML = html; 1089 | } 1090 | 1091 | if ( config.altertitle && typeof document !== "undefined" && document.title ) { 1092 | // show ✖ for good, ✔ for bad suite result in title 1093 | // use escape sequences in case file gets loaded with non-utf-8-charset 1094 | document.title = [ 1095 | ( config.stats.bad ? "\u2716" : "\u2714" ), 1096 | document.title.replace( /^[\u2714\u2716] /i, "" ) 1097 | ].join( " " ); 1098 | } 1099 | 1100 | // clear own sessionStorage items if all tests passed 1101 | if ( config.reorder && defined.sessionStorage && config.stats.bad === 0 ) { 1102 | // `key` & `i` initialized at top of scope 1103 | for ( i = 0; i < sessionStorage.length; i++ ) { 1104 | key = sessionStorage.key( i++ ); 1105 | if ( key.indexOf( "qunit-test-" ) === 0 ) { 1106 | sessionStorage.removeItem( key ); 1107 | } 1108 | } 1109 | } 1110 | 1111 | runLoggingCallbacks( "done", QUnit, { 1112 | failed: config.stats.bad, 1113 | passed: passed, 1114 | total: config.stats.all, 1115 | runtime: runtime 1116 | }); 1117 | } 1118 | 1119 | /** @return Boolean: true if this test should be ran */ 1120 | function validTest( test ) { 1121 | var include, 1122 | filter = config.filter && config.filter.toLowerCase(), 1123 | module = config.module && config.module.toLowerCase(), 1124 | fullName = (test.module + ": " + test.testName).toLowerCase(); 1125 | 1126 | if ( config.testNumber ) { 1127 | return test.testNumber === config.testNumber; 1128 | } 1129 | 1130 | if ( module && ( !test.module || test.module.toLowerCase() !== module ) ) { 1131 | return false; 1132 | } 1133 | 1134 | if ( !filter ) { 1135 | return true; 1136 | } 1137 | 1138 | include = filter.charAt( 0 ) !== "!"; 1139 | if ( !include ) { 1140 | filter = filter.slice( 1 ); 1141 | } 1142 | 1143 | // If the filter matches, we need to honour include 1144 | if ( fullName.indexOf( filter ) !== -1 ) { 1145 | return include; 1146 | } 1147 | 1148 | // Otherwise, do the opposite 1149 | return !include; 1150 | } 1151 | 1152 | // so far supports only Firefox, Chrome and Opera (buggy), Safari (for real exceptions) 1153 | // Later Safari and IE10 are supposed to support error.stack as well 1154 | // See also https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Error/Stack 1155 | function extractStacktrace( e, offset ) { 1156 | offset = offset === undefined ? 3 : offset; 1157 | 1158 | var stack, include, i, regex; 1159 | 1160 | if ( e.stacktrace ) { 1161 | // Opera 1162 | return e.stacktrace.split( "\n" )[ offset + 3 ]; 1163 | } else if ( e.stack ) { 1164 | // Firefox, Chrome 1165 | stack = e.stack.split( "\n" ); 1166 | if (/^error$/i.test( stack[0] ) ) { 1167 | stack.shift(); 1168 | } 1169 | if ( fileName ) { 1170 | include = []; 1171 | for ( i = offset; i < stack.length; i++ ) { 1172 | if ( stack[ i ].indexOf( fileName ) != -1 ) { 1173 | break; 1174 | } 1175 | include.push( stack[ i ] ); 1176 | } 1177 | if ( include.length ) { 1178 | return include.join( "\n" ); 1179 | } 1180 | } 1181 | return stack[ offset ]; 1182 | } else if ( e.sourceURL ) { 1183 | // Safari, PhantomJS 1184 | // hopefully one day Safari provides actual stacktraces 1185 | // exclude useless self-reference for generated Error objects 1186 | if ( /qunit.js$/.test( e.sourceURL ) ) { 1187 | return; 1188 | } 1189 | // for actual exceptions, this is useful 1190 | return e.sourceURL + ":" + e.line; 1191 | } 1192 | } 1193 | function sourceFromStacktrace( offset ) { 1194 | try { 1195 | throw new Error(); 1196 | } catch ( e ) { 1197 | return extractStacktrace( e, offset ); 1198 | } 1199 | } 1200 | 1201 | function escapeInnerText( s ) { 1202 | if ( !s ) { 1203 | return ""; 1204 | } 1205 | s = s + ""; 1206 | return s.replace( /[\&<>]/g, function( s ) { 1207 | switch( s ) { 1208 | case "&": return "&"; 1209 | case "<": return "<"; 1210 | case ">": return ">"; 1211 | default: return s; 1212 | } 1213 | }); 1214 | } 1215 | 1216 | function synchronize( callback, last ) { 1217 | config.queue.push( callback ); 1218 | 1219 | if ( config.autorun && !config.blocking ) { 1220 | process( last ); 1221 | } 1222 | } 1223 | 1224 | function process( last ) { 1225 | function next() { 1226 | process( last ); 1227 | } 1228 | var start = new Date().getTime(); 1229 | config.depth = config.depth ? config.depth + 1 : 1; 1230 | 1231 | while ( config.queue.length && !config.blocking ) { 1232 | if ( !defined.setTimeout || config.updateRate <= 0 || ( ( new Date().getTime() - start ) < config.updateRate ) ) { 1233 | config.queue.shift()(); 1234 | } else { 1235 | window.setTimeout( next, 13 ); 1236 | break; 1237 | } 1238 | } 1239 | config.depth--; 1240 | if ( last && !config.blocking && !config.queue.length && config.depth === 0 ) { 1241 | done(); 1242 | } 1243 | } 1244 | 1245 | function saveGlobal() { 1246 | config.pollution = []; 1247 | 1248 | if ( config.noglobals ) { 1249 | for ( var key in window ) { 1250 | // in Opera sometimes DOM element ids show up here, ignore them 1251 | if ( !hasOwn.call( window, key ) || /^qunit-test-output/.test( key ) ) { 1252 | continue; 1253 | } 1254 | config.pollution.push( key ); 1255 | } 1256 | } 1257 | } 1258 | 1259 | function checkPollution( name ) { 1260 | var newGlobals, 1261 | deletedGlobals, 1262 | old = config.pollution; 1263 | 1264 | saveGlobal(); 1265 | 1266 | newGlobals = diff( config.pollution, old ); 1267 | if ( newGlobals.length > 0 ) { 1268 | QUnit.pushFailure( "Introduced global variable(s): " + newGlobals.join(", ") ); 1269 | } 1270 | 1271 | deletedGlobals = diff( old, config.pollution ); 1272 | if ( deletedGlobals.length > 0 ) { 1273 | QUnit.pushFailure( "Deleted global variable(s): " + deletedGlobals.join(", ") ); 1274 | } 1275 | } 1276 | 1277 | // returns a new Array with the elements that are in a but not in b 1278 | function diff( a, b ) { 1279 | var i, j, 1280 | result = a.slice(); 1281 | 1282 | for ( i = 0; i < result.length; i++ ) { 1283 | for ( j = 0; j < b.length; j++ ) { 1284 | if ( result[i] === b[j] ) { 1285 | result.splice( i, 1 ); 1286 | i--; 1287 | break; 1288 | } 1289 | } 1290 | } 1291 | return result; 1292 | } 1293 | 1294 | function extend( a, b ) { 1295 | for ( var prop in b ) { 1296 | if ( b[ prop ] === undefined ) { 1297 | delete a[ prop ]; 1298 | 1299 | // Avoid "Member not found" error in IE8 caused by setting window.constructor 1300 | } else if ( prop !== "constructor" || a !== window ) { 1301 | a[ prop ] = b[ prop ]; 1302 | } 1303 | } 1304 | 1305 | return a; 1306 | } 1307 | 1308 | function addEvent( elem, type, fn ) { 1309 | if ( elem.addEventListener ) { 1310 | elem.addEventListener( type, fn, false ); 1311 | } else if ( elem.attachEvent ) { 1312 | elem.attachEvent( "on" + type, fn ); 1313 | } else { 1314 | fn(); 1315 | } 1316 | } 1317 | 1318 | function id( name ) { 1319 | return !!( typeof document !== "undefined" && document && document.getElementById ) && 1320 | document.getElementById( name ); 1321 | } 1322 | 1323 | function registerLoggingCallback( key ) { 1324 | return function( callback ) { 1325 | config[key].push( callback ); 1326 | }; 1327 | } 1328 | 1329 | // Supports deprecated method of completely overwriting logging callbacks 1330 | function runLoggingCallbacks( key, scope, args ) { 1331 | //debugger; 1332 | var i, callbacks; 1333 | if ( QUnit.hasOwnProperty( key ) ) { 1334 | QUnit[ key ].call(scope, args ); 1335 | } else { 1336 | callbacks = config[ key ]; 1337 | for ( i = 0; i < callbacks.length; i++ ) { 1338 | callbacks[ i ].call( scope, args ); 1339 | } 1340 | } 1341 | } 1342 | 1343 | // Test for equality any JavaScript type. 1344 | // Author: Philippe Rathé 1345 | QUnit.equiv = (function() { 1346 | 1347 | // Call the o related callback with the given arguments. 1348 | function bindCallbacks( o, callbacks, args ) { 1349 | var prop = QUnit.objectType( o ); 1350 | if ( prop ) { 1351 | if ( QUnit.objectType( callbacks[ prop ] ) === "function" ) { 1352 | return callbacks[ prop ].apply( callbacks, args ); 1353 | } else { 1354 | return callbacks[ prop ]; // or undefined 1355 | } 1356 | } 1357 | } 1358 | 1359 | // the real equiv function 1360 | var innerEquiv, 1361 | // stack to decide between skip/abort functions 1362 | callers = [], 1363 | // stack to avoiding loops from circular referencing 1364 | parents = [], 1365 | 1366 | getProto = Object.getPrototypeOf || function ( obj ) { 1367 | return obj.__proto__; 1368 | }, 1369 | callbacks = (function () { 1370 | 1371 | // for string, boolean, number and null 1372 | function useStrictEquality( b, a ) { 1373 | if ( b instanceof a.constructor || a instanceof b.constructor ) { 1374 | // to catch short annotaion VS 'new' annotation of a 1375 | // declaration 1376 | // e.g. var i = 1; 1377 | // var j = new Number(1); 1378 | return a == b; 1379 | } else { 1380 | return a === b; 1381 | } 1382 | } 1383 | 1384 | return { 1385 | "string": useStrictEquality, 1386 | "boolean": useStrictEquality, 1387 | "number": useStrictEquality, 1388 | "null": useStrictEquality, 1389 | "undefined": useStrictEquality, 1390 | 1391 | "nan": function( b ) { 1392 | return isNaN( b ); 1393 | }, 1394 | 1395 | "date": function( b, a ) { 1396 | return QUnit.objectType( b ) === "date" && a.valueOf() === b.valueOf(); 1397 | }, 1398 | 1399 | "regexp": function( b, a ) { 1400 | return QUnit.objectType( b ) === "regexp" && 1401 | // the regex itself 1402 | a.source === b.source && 1403 | // and its modifers 1404 | a.global === b.global && 1405 | // (gmi) ... 1406 | a.ignoreCase === b.ignoreCase && 1407 | a.multiline === b.multiline; 1408 | }, 1409 | 1410 | // - skip when the property is a method of an instance (OOP) 1411 | // - abort otherwise, 1412 | // initial === would have catch identical references anyway 1413 | "function": function() { 1414 | var caller = callers[callers.length - 1]; 1415 | return caller !== Object && typeof caller !== "undefined"; 1416 | }, 1417 | 1418 | "array": function( b, a ) { 1419 | var i, j, len, loop; 1420 | 1421 | // b could be an object literal here 1422 | if ( QUnit.objectType( b ) !== "array" ) { 1423 | return false; 1424 | } 1425 | 1426 | len = a.length; 1427 | if ( len !== b.length ) { 1428 | // safe and faster 1429 | return false; 1430 | } 1431 | 1432 | // track reference to avoid circular references 1433 | parents.push( a ); 1434 | for ( i = 0; i < len; i++ ) { 1435 | loop = false; 1436 | for ( j = 0; j < parents.length; j++ ) { 1437 | if ( parents[j] === a[i] ) { 1438 | loop = true;// dont rewalk array 1439 | } 1440 | } 1441 | if ( !loop && !innerEquiv(a[i], b[i]) ) { 1442 | parents.pop(); 1443 | return false; 1444 | } 1445 | } 1446 | parents.pop(); 1447 | return true; 1448 | }, 1449 | 1450 | "object": function( b, a ) { 1451 | var i, j, loop, 1452 | // Default to true 1453 | eq = true, 1454 | aProperties = [], 1455 | bProperties = []; 1456 | 1457 | // comparing constructors is more strict than using 1458 | // instanceof 1459 | if ( a.constructor !== b.constructor ) { 1460 | // Allow objects with no prototype to be equivalent to 1461 | // objects with Object as their constructor. 1462 | if ( !(( getProto(a) === null && getProto(b) === Object.prototype ) || 1463 | ( getProto(b) === null && getProto(a) === Object.prototype ) ) ) { 1464 | return false; 1465 | } 1466 | } 1467 | 1468 | // stack constructor before traversing properties 1469 | callers.push( a.constructor ); 1470 | // track reference to avoid circular references 1471 | parents.push( a ); 1472 | 1473 | for ( i in a ) { // be strict: don't ensures hasOwnProperty 1474 | // and go deep 1475 | loop = false; 1476 | for ( j = 0; j < parents.length; j++ ) { 1477 | if ( parents[j] === a[i] ) { 1478 | // don't go down the same path twice 1479 | loop = true; 1480 | } 1481 | } 1482 | aProperties.push(i); // collect a's properties 1483 | 1484 | if (!loop && !innerEquiv( a[i], b[i] ) ) { 1485 | eq = false; 1486 | break; 1487 | } 1488 | } 1489 | 1490 | callers.pop(); // unstack, we are done 1491 | parents.pop(); 1492 | 1493 | for ( i in b ) { 1494 | bProperties.push( i ); // collect b's properties 1495 | } 1496 | 1497 | // Ensures identical properties name 1498 | return eq && innerEquiv( aProperties.sort(), bProperties.sort() ); 1499 | } 1500 | }; 1501 | }()); 1502 | 1503 | innerEquiv = function() { // can take multiple arguments 1504 | var args = [].slice.apply( arguments ); 1505 | if ( args.length < 2 ) { 1506 | return true; // end transition 1507 | } 1508 | 1509 | return (function( a, b ) { 1510 | if ( a === b ) { 1511 | return true; // catch the most you can 1512 | } else if ( a === null || b === null || typeof a === "undefined" || 1513 | typeof b === "undefined" || 1514 | QUnit.objectType(a) !== QUnit.objectType(b) ) { 1515 | return false; // don't lose time with error prone cases 1516 | } else { 1517 | return bindCallbacks(a, callbacks, [ b, a ]); 1518 | } 1519 | 1520 | // apply transition with (1..n) arguments 1521 | }( args[0], args[1] ) && arguments.callee.apply( this, args.splice(1, args.length - 1 )) ); 1522 | }; 1523 | 1524 | return innerEquiv; 1525 | }()); 1526 | 1527 | /** 1528 | * jsDump Copyright (c) 2008 Ariel Flesler - aflesler(at)gmail(dot)com | 1529 | * http://flesler.blogspot.com Licensed under BSD 1530 | * (http://www.opensource.org/licenses/bsd-license.php) Date: 5/15/2008 1531 | * 1532 | * @projectDescription Advanced and extensible data dumping for Javascript. 1533 | * @version 1.0.0 1534 | * @author Ariel Flesler 1535 | * @link {http://flesler.blogspot.com/2008/05/jsdump-pretty-dump-of-any-javascript.html} 1536 | */ 1537 | QUnit.jsDump = (function() { 1538 | function quote( str ) { 1539 | return '"' + str.toString().replace( /"/g, '\\"' ) + '"'; 1540 | } 1541 | function literal( o ) { 1542 | return o + ""; 1543 | } 1544 | function join( pre, arr, post ) { 1545 | var s = jsDump.separator(), 1546 | base = jsDump.indent(), 1547 | inner = jsDump.indent(1); 1548 | if ( arr.join ) { 1549 | arr = arr.join( "," + s + inner ); 1550 | } 1551 | if ( !arr ) { 1552 | return pre + post; 1553 | } 1554 | return [ pre, inner + arr, base + post ].join(s); 1555 | } 1556 | function array( arr, stack ) { 1557 | var i = arr.length, ret = new Array(i); 1558 | this.up(); 1559 | while ( i-- ) { 1560 | ret[i] = this.parse( arr[i] , undefined , stack); 1561 | } 1562 | this.down(); 1563 | return join( "[", ret, "]" ); 1564 | } 1565 | 1566 | var reName = /^function (\w+)/, 1567 | jsDump = { 1568 | parse: function( obj, type, stack ) { //type is used mostly internally, you can fix a (custom)type in advance 1569 | stack = stack || [ ]; 1570 | var inStack, res, 1571 | parser = this.parsers[ type || this.typeOf(obj) ]; 1572 | 1573 | type = typeof parser; 1574 | inStack = inArray( obj, stack ); 1575 | 1576 | if ( inStack != -1 ) { 1577 | return "recursion(" + (inStack - stack.length) + ")"; 1578 | } 1579 | //else 1580 | if ( type == "function" ) { 1581 | stack.push( obj ); 1582 | res = parser.call( this, obj, stack ); 1583 | stack.pop(); 1584 | return res; 1585 | } 1586 | // else 1587 | return ( type == "string" ) ? parser : this.parsers.error; 1588 | }, 1589 | typeOf: function( obj ) { 1590 | var type; 1591 | if ( obj === null ) { 1592 | type = "null"; 1593 | } else if ( typeof obj === "undefined" ) { 1594 | type = "undefined"; 1595 | } else if ( QUnit.is( "regexp", obj) ) { 1596 | type = "regexp"; 1597 | } else if ( QUnit.is( "date", obj) ) { 1598 | type = "date"; 1599 | } else if ( QUnit.is( "function", obj) ) { 1600 | type = "function"; 1601 | } else if ( typeof obj.setInterval !== undefined && typeof obj.document !== "undefined" && typeof obj.nodeType === "undefined" ) { 1602 | type = "window"; 1603 | } else if ( obj.nodeType === 9 ) { 1604 | type = "document"; 1605 | } else if ( obj.nodeType ) { 1606 | type = "node"; 1607 | } else if ( 1608 | // native arrays 1609 | toString.call( obj ) === "[object Array]" || 1610 | // NodeList objects 1611 | ( typeof obj.length === "number" && typeof obj.item !== "undefined" && ( obj.length ? obj.item(0) === obj[0] : ( obj.item( 0 ) === null && typeof obj[0] === "undefined" ) ) ) 1612 | ) { 1613 | type = "array"; 1614 | } else { 1615 | type = typeof obj; 1616 | } 1617 | return type; 1618 | }, 1619 | separator: function() { 1620 | return this.multiline ? this.HTML ? "
    " : "\n" : this.HTML ? " " : " "; 1621 | }, 1622 | indent: function( extra ) {// extra can be a number, shortcut for increasing-calling-decreasing 1623 | if ( !this.multiline ) { 1624 | return ""; 1625 | } 1626 | var chr = this.indentChar; 1627 | if ( this.HTML ) { 1628 | chr = chr.replace( /\t/g, " " ).replace( / /g, " " ); 1629 | } 1630 | return new Array( this._depth_ + (extra||0) ).join(chr); 1631 | }, 1632 | up: function( a ) { 1633 | this._depth_ += a || 1; 1634 | }, 1635 | down: function( a ) { 1636 | this._depth_ -= a || 1; 1637 | }, 1638 | setParser: function( name, parser ) { 1639 | this.parsers[name] = parser; 1640 | }, 1641 | // The next 3 are exposed so you can use them 1642 | quote: quote, 1643 | literal: literal, 1644 | join: join, 1645 | // 1646 | _depth_: 1, 1647 | // This is the list of parsers, to modify them, use jsDump.setParser 1648 | parsers: { 1649 | window: "[Window]", 1650 | document: "[Document]", 1651 | error: "[ERROR]", //when no parser is found, shouldn"t happen 1652 | unknown: "[Unknown]", 1653 | "null": "null", 1654 | "undefined": "undefined", 1655 | "function": function( fn ) { 1656 | var ret = "function", 1657 | name = "name" in fn ? fn.name : (reName.exec(fn) || [])[1];//functions never have name in IE 1658 | 1659 | if ( name ) { 1660 | ret += " " + name; 1661 | } 1662 | ret += "( "; 1663 | 1664 | ret = [ ret, QUnit.jsDump.parse( fn, "functionArgs" ), "){" ].join( "" ); 1665 | return join( ret, QUnit.jsDump.parse(fn,"functionCode" ), "}" ); 1666 | }, 1667 | array: array, 1668 | nodelist: array, 1669 | "arguments": array, 1670 | object: function( map, stack ) { 1671 | var ret = [ ], keys, key, val, i; 1672 | QUnit.jsDump.up(); 1673 | if ( Object.keys ) { 1674 | keys = Object.keys( map ); 1675 | } else { 1676 | keys = []; 1677 | for ( key in map ) { 1678 | keys.push( key ); 1679 | } 1680 | } 1681 | keys.sort(); 1682 | for ( i = 0; i < keys.length; i++ ) { 1683 | key = keys[ i ]; 1684 | val = map[ key ]; 1685 | ret.push( QUnit.jsDump.parse( key, "key" ) + ": " + QUnit.jsDump.parse( val, undefined, stack ) ); 1686 | } 1687 | QUnit.jsDump.down(); 1688 | return join( "{", ret, "}" ); 1689 | }, 1690 | node: function( node ) { 1691 | var a, val, 1692 | open = QUnit.jsDump.HTML ? "<" : "<", 1693 | close = QUnit.jsDump.HTML ? ">" : ">", 1694 | tag = node.nodeName.toLowerCase(), 1695 | ret = open + tag; 1696 | 1697 | for ( a in QUnit.jsDump.DOMAttrs ) { 1698 | val = node[ QUnit.jsDump.DOMAttrs[a] ]; 1699 | if ( val ) { 1700 | ret += " " + a + "=" + QUnit.jsDump.parse( val, "attribute" ); 1701 | } 1702 | } 1703 | return ret + close + open + "/" + tag + close; 1704 | }, 1705 | functionArgs: function( fn ) {//function calls it internally, it's the arguments part of the function 1706 | var args, 1707 | l = fn.length; 1708 | 1709 | if ( !l ) { 1710 | return ""; 1711 | } 1712 | 1713 | args = new Array(l); 1714 | while ( l-- ) { 1715 | args[l] = String.fromCharCode(97+l);//97 is 'a' 1716 | } 1717 | return " " + args.join( ", " ) + " "; 1718 | }, 1719 | key: quote, //object calls it internally, the key part of an item in a map 1720 | functionCode: "[code]", //function calls it internally, it's the content of the function 1721 | attribute: quote, //node calls it internally, it's an html attribute value 1722 | string: quote, 1723 | date: quote, 1724 | regexp: literal, //regex 1725 | number: literal, 1726 | "boolean": literal 1727 | }, 1728 | DOMAttrs: { 1729 | //attributes to dump from nodes, name=>realName 1730 | id: "id", 1731 | name: "name", 1732 | "class": "className" 1733 | }, 1734 | HTML: false,//if true, entities are escaped ( <, >, \t, space and \n ) 1735 | indentChar: " ",//indentation unit 1736 | multiline: true //if true, items in a collection, are separated by a \n, else just a space. 1737 | }; 1738 | 1739 | return jsDump; 1740 | }()); 1741 | 1742 | // from Sizzle.js 1743 | function getText( elems ) { 1744 | var i, elem, 1745 | ret = ""; 1746 | 1747 | for ( i = 0; elems[i]; i++ ) { 1748 | elem = elems[i]; 1749 | 1750 | // Get the text from text nodes and CDATA nodes 1751 | if ( elem.nodeType === 3 || elem.nodeType === 4 ) { 1752 | ret += elem.nodeValue; 1753 | 1754 | // Traverse everything else, except comment nodes 1755 | } else if ( elem.nodeType !== 8 ) { 1756 | ret += getText( elem.childNodes ); 1757 | } 1758 | } 1759 | 1760 | return ret; 1761 | } 1762 | 1763 | // from jquery.js 1764 | function inArray( elem, array ) { 1765 | if ( array.indexOf ) { 1766 | return array.indexOf( elem ); 1767 | } 1768 | 1769 | for ( var i = 0, length = array.length; i < length; i++ ) { 1770 | if ( array[ i ] === elem ) { 1771 | return i; 1772 | } 1773 | } 1774 | 1775 | return -1; 1776 | } 1777 | 1778 | /* 1779 | * Javascript Diff Algorithm 1780 | * By John Resig (http://ejohn.org/) 1781 | * Modified by Chu Alan "sprite" 1782 | * 1783 | * Released under the MIT license. 1784 | * 1785 | * More Info: 1786 | * http://ejohn.org/projects/javascript-diff-algorithm/ 1787 | * 1788 | * Usage: QUnit.diff(expected, actual) 1789 | * 1790 | * QUnit.diff( "the quick brown fox jumped over", "the quick fox jumps over" ) == "the quick brown fox jumped jumps over" 1791 | */ 1792 | QUnit.diff = (function() { 1793 | function diff( o, n ) { 1794 | var i, 1795 | ns = {}, 1796 | os = {}; 1797 | 1798 | for ( i = 0; i < n.length; i++ ) { 1799 | if ( ns[ n[i] ] == null ) { 1800 | ns[ n[i] ] = { 1801 | rows: [], 1802 | o: null 1803 | }; 1804 | } 1805 | ns[ n[i] ].rows.push( i ); 1806 | } 1807 | 1808 | for ( i = 0; i < o.length; i++ ) { 1809 | if ( os[ o[i] ] == null ) { 1810 | os[ o[i] ] = { 1811 | rows: [], 1812 | n: null 1813 | }; 1814 | } 1815 | os[ o[i] ].rows.push( i ); 1816 | } 1817 | 1818 | for ( i in ns ) { 1819 | if ( !hasOwn.call( ns, i ) ) { 1820 | continue; 1821 | } 1822 | if ( ns[i].rows.length == 1 && typeof os[i] != "undefined" && os[i].rows.length == 1 ) { 1823 | n[ ns[i].rows[0] ] = { 1824 | text: n[ ns[i].rows[0] ], 1825 | row: os[i].rows[0] 1826 | }; 1827 | o[ os[i].rows[0] ] = { 1828 | text: o[ os[i].rows[0] ], 1829 | row: ns[i].rows[0] 1830 | }; 1831 | } 1832 | } 1833 | 1834 | for ( i = 0; i < n.length - 1; i++ ) { 1835 | if ( n[i].text != null && n[ i + 1 ].text == null && n[i].row + 1 < o.length && o[ n[i].row + 1 ].text == null && 1836 | n[ i + 1 ] == o[ n[i].row + 1 ] ) { 1837 | 1838 | n[ i + 1 ] = { 1839 | text: n[ i + 1 ], 1840 | row: n[i].row + 1 1841 | }; 1842 | o[ n[i].row + 1 ] = { 1843 | text: o[ n[i].row + 1 ], 1844 | row: i + 1 1845 | }; 1846 | } 1847 | } 1848 | 1849 | for ( i = n.length - 1; i > 0; i-- ) { 1850 | if ( n[i].text != null && n[ i - 1 ].text == null && n[i].row > 0 && o[ n[i].row - 1 ].text == null && 1851 | n[ i - 1 ] == o[ n[i].row - 1 ]) { 1852 | 1853 | n[ i - 1 ] = { 1854 | text: n[ i - 1 ], 1855 | row: n[i].row - 1 1856 | }; 1857 | o[ n[i].row - 1 ] = { 1858 | text: o[ n[i].row - 1 ], 1859 | row: i - 1 1860 | }; 1861 | } 1862 | } 1863 | 1864 | return { 1865 | o: o, 1866 | n: n 1867 | }; 1868 | } 1869 | 1870 | return function( o, n ) { 1871 | o = o.replace( /\s+$/, "" ); 1872 | n = n.replace( /\s+$/, "" ); 1873 | 1874 | var i, pre, 1875 | str = "", 1876 | out = diff( o === "" ? [] : o.split(/\s+/), n === "" ? [] : n.split(/\s+/) ), 1877 | oSpace = o.match(/\s+/g), 1878 | nSpace = n.match(/\s+/g); 1879 | 1880 | if ( oSpace == null ) { 1881 | oSpace = [ " " ]; 1882 | } 1883 | else { 1884 | oSpace.push( " " ); 1885 | } 1886 | 1887 | if ( nSpace == null ) { 1888 | nSpace = [ " " ]; 1889 | } 1890 | else { 1891 | nSpace.push( " " ); 1892 | } 1893 | 1894 | if ( out.n.length === 0 ) { 1895 | for ( i = 0; i < out.o.length; i++ ) { 1896 | str += "" + out.o[i] + oSpace[i] + ""; 1897 | } 1898 | } 1899 | else { 1900 | if ( out.n[0].text == null ) { 1901 | for ( n = 0; n < out.o.length && out.o[n].text == null; n++ ) { 1902 | str += "" + out.o[n] + oSpace[n] + ""; 1903 | } 1904 | } 1905 | 1906 | for ( i = 0; i < out.n.length; i++ ) { 1907 | if (out.n[i].text == null) { 1908 | str += "" + out.n[i] + nSpace[i] + ""; 1909 | } 1910 | else { 1911 | // `pre` initialized at top of scope 1912 | pre = ""; 1913 | 1914 | for ( n = out.n[i].row + 1; n < out.o.length && out.o[n].text == null; n++ ) { 1915 | pre += "" + out.o[n] + oSpace[n] + ""; 1916 | } 1917 | str += " " + out.n[i].text + nSpace[i] + pre; 1918 | } 1919 | } 1920 | } 1921 | 1922 | return str; 1923 | }; 1924 | }()); 1925 | 1926 | // for CommonJS enviroments, export everything 1927 | if ( typeof exports !== "undefined" ) { 1928 | extend(exports, QUnit); 1929 | } 1930 | 1931 | // get at whatever the global object is, like window in browsers 1932 | }( (function() {return this;}.call()) )); 1933 | --------------------------------------------------------------------------------