├── .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 | 
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 | 
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 | 
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 | "" +
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 += "| Expected: | " + expected + " |
";
798 |
799 | if ( actual != expected ) {
800 | output += "| Result: | " + actual + " |
";
801 | output += "| Diff: | " + QUnit.diff( expected, actual ) + " |
";
802 | }
803 |
804 | source = sourceFromStacktrace();
805 |
806 | if ( source ) {
807 | details.source = source;
808 | output += "| Source: | " + escapeInnerText( source ) + " |
";
809 | }
810 |
811 | output += "
";
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 += "| Result: | " + escapeInnerText( actual ) + " |
";
841 | }
842 |
843 | if ( source ) {
844 | details.source = source;
845 | output += "| Source: | " + escapeInnerText( source ) + " |
";
846 | }
847 |
848 | output += "
";
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 |
--------------------------------------------------------------------------------