1// jquery.pjax.js
2// copyright chris wanstrath
3// https://github.com/defunkt/jquery-pjax
4
5(function($){
6
7// When called on a container with a selector, fetches the href with
8// ajax into the container or with the data-pjax attribute on the link
9// itself.
10//
11// Tries to make sure the back button and ctrl+click work the way
12// you'd expect.
13//
14// Exported as $.fn.pjax
15//
16// Accepts a jQuery ajax options object that may include these
17// pjax specific options:
18//
19//
20// container - Where to stick the response body. Usually a String selector.
21//             $(container).html(xhr.responseBody)
22//             (default: current jquery context)
23//      push - Whether to pushState the URL. Defaults to true (of course).
24//   replace - Want to use replaceState instead? That's cool.
25//
26// For convenience the second parameter can be either the container or
27// the options object.
28//
29// Returns the jQuery object
30function fnPjax(selector, container, options) {
31  var context = this
32  return this.on('click.pjax', selector, function(event) {
33    var opts = $.extend({}, optionsFor(container, options))
34    if (!opts.container)
35      opts.container = $(this).attr('data-pjax') || context
36    handleClick(event, opts)
37  })
38}
39
40// Public: pjax on click handler
41//
42// Exported as $.pjax.click.
43//
44// event   - "click" jQuery.Event
45// options - pjax options
46//
47// Examples
48//
49//   $(document).on('click', 'a', $.pjax.click)
50//   // is the same as
51//   $(document).pjax('a')
52//
53//  $(document).on('click', 'a', function(event) {
54//    var container = $(this).closest('[data-pjax-container]')
55//    $.pjax.click(event, container)
56//  })
57//
58// Returns nothing.
59function handleClick(event, container, options) {
60  options = optionsFor(container, options)
61
62  var link = event.currentTarget
63
64  if (link.tagName.toUpperCase() !== 'A')
65    throw "$.fn.pjax or $.pjax.click requires an anchor element"
66
67  // Middle click, cmd click, and ctrl click should open
68  // links in a new tab as normal.
69  if ( event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey )
70    return
71
72  // Ignore cross origin links
73  if ( location.protocol !== link.protocol || location.hostname !== link.hostname )
74    return
75
76  // Ignore anchors on the same page
77  if (link.hash && link.href.replace(link.hash, '') ===
78       location.href.replace(location.hash, ''))
79    return
80
81  // Ignore empty anchor "foo.html#"
82  if (link.href === location.href + '#')
83    return
84
85  var defaults = {
86    url: link.href,
87    container: $(link).attr('data-pjax'),
88    target: link,
89    fragment: null
90  }
91
92  var opts = $.extend({}, defaults, options)
93  var clickEvent = $.Event('pjax:click')
94  $(link).trigger(clickEvent, [opts])
95
96  if (!clickEvent.isDefaultPrevented()) {
97    pjax(opts)
98    event.preventDefault()
99  }
100}
101
102// Public: pjax on form submit handler
103//
104// Exported as $.pjax.submit
105//
106// event   - "click" jQuery.Event
107// options - pjax options
108//
109// Examples
110//
111//  $(document).on('submit', 'form', function(event) {
112//    var container = $(this).closest('[data-pjax-container]')
113//    $.pjax.submit(event, container)
114//  })
115//
116// Returns nothing.
117function handleSubmit(event, container, options) {
118  options = optionsFor(container, options)
119
120  var form = event.currentTarget
121
122  if (form.tagName.toUpperCase() !== 'FORM')
123    throw "$.pjax.submit requires a form element"
124
125  var defaults = {
126    type: form.method.toUpperCase(),
127    url: form.action,
128    data: $(form).serializeArray(),
129    container: $(form).attr('data-pjax'),
130    target: form,
131    fragment: null
132  }
133
134  pjax($.extend({}, defaults, options))
135
136  event.preventDefault()
137}
138
139// Loads a URL with ajax, puts the response body inside a container,
140// then pushState()'s the loaded URL.
141//
142// Works just like $.ajax in that it accepts a jQuery ajax
143// settings object (with keys like url, type, data, etc).
144//
145// Accepts these extra keys:
146//
147// container - Where to stick the response body.
148//             $(container).html(xhr.responseBody)
149//      push - Whether to pushState the URL. Defaults to true (of course).
150//   replace - Want to use replaceState instead? That's cool.
151//
152// Use it just like $.ajax:
153//
154//   var xhr = $.pjax({ url: this.href, container: '#main' })
155//   console.log( xhr.readyState )
156//
157// Returns whatever $.ajax returns.
158function pjax(options) {
159  options = $.extend(true, {}, $.ajaxSettings, pjax.defaults, options)
160
161  if ($.isFunction(options.url)) {
162    options.url = options.url()
163  }
164
165  var target = options.target
166
167  var hash = parseURL(options.url).hash
168
169  var context = options.context = findContainerFor(options.container)
170
171  // We want the browser to maintain two separate internal caches: one
172  // for pjax'd partial page loads and one for normal page loads.
173  // Without adding this secret parameter, some browsers will often
174  // confuse the two.
175  if (!options.data) options.data = {}
176  options.data._pjax = context.selector
177
178  function fire(type, args) {
179    var event = $.Event(type, { relatedTarget: target })
180    context.trigger(event, args)
181    return !event.isDefaultPrevented()
182  }
183
184  var timeoutTimer
185
186  options.beforeSend = function(xhr, settings) {
187    // No timeout for non-GET requests
188    // Its not safe to request the resource again with a fallback method.
189    if (settings.type !== 'GET') {
190      settings.timeout = 0
191    }
192
193    xhr.setRequestHeader('X-PJAX', 'true')
194    xhr.setRequestHeader('X-PJAX-Container', context.selector)
195
196    if (!fire('pjax:beforeSend', [xhr, settings]))
197      return false
198
199    if (settings.timeout > 0) {
200      timeoutTimer = setTimeout(function() {
201        if (fire('pjax:timeout', [xhr, options]))
202          xhr.abort('timeout')
203      }, settings.timeout)
204
205      // Clear timeout setting so jquerys internal timeout isn't invoked
206      settings.timeout = 0
207    }
208
209    options.requestUrl = parseURL(settings.url).href
210  }
211
212  options.complete = function(xhr, textStatus) {
213    if (timeoutTimer)
214      clearTimeout(timeoutTimer)
215
216    fire('pjax:complete', [xhr, textStatus, options])
217
218    fire('pjax:end', [xhr, options])
219  }
220
221  options.error = function(xhr, textStatus, errorThrown) {
222    var container = extractContainer("", xhr, options)
223
224    var allowed = fire('pjax:error', [xhr, textStatus, errorThrown, options])
225    if (options.type == 'GET' && textStatus !== 'abort' && allowed) {
226      locationReplace(container.url)
227    }
228  }
229
230  options.success = function(data, status, xhr) {
231    // If $.pjax.defaults.version is a function, invoke it first.
232    // Otherwise it can be a static string.
233    var currentVersion = (typeof $.pjax.defaults.version === 'function') ?
234      $.pjax.defaults.version() :
235      $.pjax.defaults.version
236
237    var latestVersion = xhr.getResponseHeader('X-PJAX-Version')
238
239    var container = extractContainer(data, xhr, options)
240
241    // If there is a layout version mismatch, hard load the new url
242    if (currentVersion && latestVersion && currentVersion !== latestVersion) {
243      locationReplace(container.url)
244      return
245    }
246
247    // If the new response is missing a body, hard load the page
248    if (!container.contents) {
249      locationReplace(container.url)
250      return
251    }
252
253    pjax.state = {
254      id: options.id || uniqueId(),
255      url: container.url,
256      title: container.title,
257      container: context.selector,
258      fragment: options.fragment,
259      timeout: options.timeout
260    }
261
262    if (options.push || options.replace) {
263      window.history.replaceState(pjax.state, container.title, container.url)
264    }
265
266    if (container.title) document.title = container.title
267    context.html(container.contents)
268    executeScriptTags(container.scripts)
269
270    // Scroll to top by default
271    if (typeof options.scrollTo === 'number')
272      $(window).scrollTop(options.scrollTo)
273
274    // If the URL has a hash in it, make sure the browser
275    // knows to navigate to the hash.
276    if ( hash !== '' ) {
277      // Avoid using simple hash set here. Will add another history
278      // entry. Replace the url with replaceState and scroll to target
279      // by hand.
280      //
281      //   window.location.hash = hash
282      var url = parseURL(container.url)
283      url.hash = hash
284
285      pjax.state.url = url.href
286      window.history.replaceState(pjax.state, container.title, url.href)
287
288      var target = $(url.hash)
289      if (target.length) $(window).scrollTop(target.offset().top)
290    }
291
292    fire('pjax:success', [data, status, xhr, options])
293  }
294
295
296  // Initialize pjax.state for the initial page load. Assume we're
297  // using the container and options of the link we're loading for the
298  // back button to the initial page. This ensures good back button
299  // behavior.
300  if (!pjax.state) {
301    pjax.state = {
302      id: uniqueId(),
303      url: window.location.href,
304      title: document.title,
305      container: context.selector,
306      fragment: options.fragment,
307      timeout: options.timeout
308    }
309    window.history.replaceState(pjax.state, document.title)
310  }
311
312  // Cancel the current request if we're already pjaxing
313  var xhr = pjax.xhr
314  if ( xhr && xhr.readyState < 4) {
315    xhr.onreadystatechange = $.noop
316    xhr.abort()
317  }
318
319  pjax.options = options
320  var xhr = pjax.xhr = $.ajax(options)
321
322  if (xhr.readyState > 0) {
323    if (options.push && !options.replace) {
324      // Cache current container element before replacing it
325      cachePush(pjax.state.id, context.clone().contents())
326
327      window.history.pushState(null, "", stripPjaxParam(options.requestUrl))
328    }
329
330    fire('pjax:start', [xhr, options])
331    fire('pjax:send', [xhr, options])
332  }
333
334  return pjax.xhr
335}
336
337// Public: Reload current page with pjax.
338//
339// Returns whatever $.pjax returns.
340function pjaxReload(container, options) {
341  var defaults = {
342    url: window.location.href,
343    push: false,
344    replace: true,
345    scrollTo: false
346  }
347
348  return pjax($.extend(defaults, optionsFor(container, options)))
349}
350
351// Internal: Hard replace current state with url.
352//
353// Work for around WebKit
354//   https://bugs.webkit.org/show_bug.cgi?id=93506
355//
356// Returns nothing.
357function locationReplace(url) {
358  window.history.replaceState(null, "", "#")
359  window.location.replace(url)
360}
361
362
363var initialPop = true
364var initialURL = window.location.href
365var initialState = window.history.state
366
367// Initialize $.pjax.state if possible
368// Happens when reloading a page and coming forward from a different
369// session history.
370if (initialState && initialState.container) {
371  pjax.state = initialState
372}
373
374// Non-webkit browsers don't fire an initial popstate event
375if ('state' in window.history) {
376  initialPop = false
377}
378
379// popstate handler takes care of the back and forward buttons
380//
381// You probably shouldn't use pjax on pages with other pushState
382// stuff yet.
383function onPjaxPopstate(event) {
384  var state = event.state
385
386  if (state && state.container) {
387    // When coming forward from a seperate history session, will get an
388    // initial pop with a state we are already at. Skip reloading the current
389    // page.
390    if (initialPop && initialURL == state.url) return
391
392    var container = $(state.container)
393    if (container.length) {
394      var direction, contents = cacheMapping[state.id]
395
396      if (pjax.state) {
397        // Since state ids always increase, we can deduce the history
398        // direction from the previous state.
399        direction = pjax.state.id < state.id ? 'forward' : 'back'
400
401        // Cache current container before replacement and inform the
402        // cache which direction the history shifted.
403        cachePop(direction, pjax.state.id, container.clone().contents())
404      }
405
406      var popstateEvent = $.Event('pjax:popstate', {
407        state: state,
408        direction: direction
409      })
410      container.trigger(popstateEvent)
411
412      var options = {
413        id: state.id,
414        url: state.url,
415        container: container,
416        push: false,
417        fragment: state.fragment,
418        timeout: state.timeout,
419        scrollTo: false
420      }
421
422      if (contents) {
423        container.trigger('pjax:start', [null, options])
424
425        if (state.title) document.title = state.title
426        container.html(contents)
427        pjax.state = state
428
429        container.trigger('pjax:end', [null, options])
430      } else {
431        pjax(options)
432      }
433
434      // Force reflow/relayout before the browser tries to restore the
435      // scroll position.
436      container[0].offsetHeight
437    } else {
438      locationReplace(location.href)
439    }
440  }
441  initialPop = false
442}
443
444// Fallback version of main pjax function for browsers that don't
445// support pushState.
446//
447// Returns nothing since it retriggers a hard form submission.
448function fallbackPjax(options) {
449  var url = $.isFunction(options.url) ? options.url() : options.url,
450      method = options.type ? options.type.toUpperCase() : 'GET'
451
452  var form = $('<form>', {
453    method: method === 'GET' ? 'GET' : 'POST',
454    action: url,
455    style: 'display:none'
456  })
457
458  if (method !== 'GET' && method !== 'POST') {
459    form.append($('<input>', {
460      type: 'hidden',
461      name: '_method',
462      value: method.toLowerCase()
463    }))
464  }
465
466  var data = options.data
467  if (typeof data === 'string') {
468    $.each(data.split('&'), function(index, value) {
469      var pair = value.split('=')
470      form.append($('<input>', {type: 'hidden', name: pair[0], value: pair[1]}))
471    })
472  } else if (typeof data === 'object') {
473    for (key in data)
474      form.append($('<input>', {type: 'hidden', name: key, value: data[key]}))
475  }
476
477  $(document.body).append(form)
478  form.submit()
479}
480
481// Internal: Generate unique id for state object.
482//
483// Use a timestamp instead of a counter since ids should still be
484// unique across page loads.
485//
486// Returns Number.
487function uniqueId() {
488  return (new Date).getTime()
489}
490
491// Internal: Strips _pjax param from url
492//
493// url - String
494//
495// Returns String.
496function stripPjaxParam(url) {
497  return url
498    .replace(/\?_pjax=[^&]+&?/, '?')
499    .replace(/_pjax=[^&]+&?/, '')
500    .replace(/[\?&]$/, '')
501}
502
503// Internal: Parse URL components and returns a Locationish object.
504//
505// url - String URL
506//
507// Returns HTMLAnchorElement that acts like Location.
508function parseURL(url) {
509  var a = document.createElement('a')
510  a.href = url
511  return a
512}
513
514// Internal: Build options Object for arguments.
515//
516// For convenience the first parameter can be either the container or
517// the options object.
518//
519// Examples
520//
521//   optionsFor('#container')
522//   // => {container: '#container'}
523//
524//   optionsFor('#container', {push: true})
525//   // => {container: '#container', push: true}
526//
527//   optionsFor({container: '#container', push: true})
528//   // => {container: '#container', push: true}
529//
530// Returns options Object.
531function optionsFor(container, options) {
532  // Both container and options
533  if ( container && options )
534    options.container = container
535
536  // First argument is options Object
537  else if ( $.isPlainObject(container) )
538    options = container
539
540  // Only container
541  else
542    options = {container: container}
543
544  // Find and validate container
545  if (options.container)
546    options.container = findContainerFor(options.container)
547
548  return options
549}
550
551// Internal: Find container element for a variety of inputs.
552//
553// Because we can't persist elements using the history API, we must be
554// able to find a String selector that will consistently find the Element.
555//
556// container - A selector String, jQuery object, or DOM Element.
557//
558// Returns a jQuery object whose context is `document` and has a selector.
559function findContainerFor(container) {
560  container = $(container)
561
562  if ( !container.length ) {
563    throw "no pjax container for " + container.selector
564  } else if ( container.selector !== '' && container.context === document ) {
565    return container
566  } else if ( container.attr('id') ) {
567    return $('#' + container.attr('id'))
568  } else {
569    throw "cant get selector for pjax container!"
570  }
571}
572
573// Internal: Filter and find all elements matching the selector.
574//
575// Where $.fn.find only matches descendants, findAll will test all the
576// top level elements in the jQuery object as well.
577//
578// elems    - jQuery object of Elements
579// selector - String selector to match
580//
581// Returns a jQuery object.
582function findAll(elems, selector) {
583  return elems.filter(selector).add(elems.find(selector));
584}
585
586function parseHTML(html) {
587  return $.parseHTML(html, document, true)
588}
589
590// Internal: Extracts container and metadata from response.
591//
592// 1. Extracts X-PJAX-URL header if set
593// 2. Extracts inline <title> tags
594// 3. Builds response Element and extracts fragment if set
595//
596// data    - String response data
597// xhr     - XHR response
598// options - pjax options Object
599//
600// Returns an Object with url, title, and contents keys.
601function extractContainer(data, xhr, options) {
602  var obj = {}
603
604  // Prefer X-PJAX-URL header if it was set, otherwise fallback to
605  // using the original requested url.
606  obj.url = stripPjaxParam(xhr.getResponseHeader('X-PJAX-URL') || options.requestUrl)
607
608  // Attempt to parse response html into elements
609  if (/<html/i.test(data)) {
610    var $head = $(parseHTML(data.match(/<head[^>]*>([\s\S.]*)<\/head>/i)[0]))
611    var $body = $(parseHTML(data.match(/<body[^>]*>([\s\S.]*)<\/body>/i)[0]))
612  } else {
613    var $head = $body = $(parseHTML(data))
614  }
615
616  // If response data is empty, return fast
617  if ($body.length === 0)
618    return obj
619
620  // If there's a <title> tag in the header, use it as
621  // the page's title.
622  obj.title = findAll($head, 'title').last().text()
623
624  if (options.fragment) {
625    // If they specified a fragment, look for it in the response
626    // and pull it out.
627    if (options.fragment === 'body') {
628      var $fragment = $body
629    } else {
630      var $fragment = findAll($body, options.fragment).first()
631    }
632
633    if ($fragment.length) {
634      obj.contents = $fragment.contents()
635
636      // If there's no title, look for data-title and title attributes
637      // on the fragment
638      if (!obj.title)
639        obj.title = $fragment.attr('title') || $fragment.data('title')
640    }
641
642  } else if (!/<html/i.test(data)) {
643    obj.contents = $body
644  }
645
646  // Clean up any <title> tags
647  if (obj.contents) {
648    // Remove any parent title elements
649    obj.contents = obj.contents.not(function() { return $(this).is('title') })
650
651    // Then scrub any titles from their descendents
652    obj.contents.find('title').remove()
653
654    // Gather all script[src] elements
655    obj.scripts = findAll(obj.contents, 'script[src]').remove()
656    obj.contents = obj.contents.not(obj.scripts)
657  }
658
659  // Trim any whitespace off the title
660  if (obj.title) obj.title = $.trim(obj.title)
661
662  return obj
663}
664
665// Load an execute scripts using standard script request.
666//
667// Avoids jQuery's traditional $.getScript which does a XHR request and
668// globalEval.
669//
670// scripts - jQuery object of script Elements
671//
672// Returns nothing.
673function executeScriptTags(scripts) {
674  if (!scripts) return
675
676  var existingScripts = $('script[src]')
677
678  scripts.each(function() {
679    var src = this.src
680    var matchedScripts = existingScripts.filter(function() {
681      return this.src === src
682    })
683    if (matchedScripts.length) return
684
685    var script = document.createElement('script')
686    script.type = $(this).attr('type')
687    script.src = $(this).attr('src')
688    document.head.appendChild(script)
689  })
690}
691
692// Internal: History DOM caching class.
693var cacheMapping      = {}
694var cacheForwardStack = []
695var cacheBackStack    = []
696
697// Push previous state id and container contents into the history
698// cache. Should be called in conjunction with `pushState` to save the
699// previous container contents.
700//
701// id    - State ID Number
702// value - DOM Element to cache
703//
704// Returns nothing.
705function cachePush(id, value) {
706  cacheMapping[id] = value
707  cacheBackStack.push(id)
708
709  // Remove all entires in forward history stack after pushing
710  // a new page.
711  while (cacheForwardStack.length)
712    delete cacheMapping[cacheForwardStack.shift()]
713
714  // Trim back history stack to max cache length.
715  while (cacheBackStack.length > pjax.defaults.maxCacheLength)
716    delete cacheMapping[cacheBackStack.shift()]
717}
718
719// Shifts cache from directional history cache. Should be
720// called on `popstate` with the previous state id and container
721// contents.
722//
723// direction - "forward" or "back" String
724// id        - State ID Number
725// value     - DOM Element to cache
726//
727// Returns nothing.
728function cachePop(direction, id, value) {
729  var pushStack, popStack
730  cacheMapping[id] = value
731
732  if (direction === 'forward') {
733    pushStack = cacheBackStack
734    popStack  = cacheForwardStack
735  } else {
736    pushStack = cacheForwardStack
737    popStack  = cacheBackStack
738  }
739
740  pushStack.push(id)
741  if (id = popStack.pop())
742    delete cacheMapping[id]
743}
744
745// Public: Find version identifier for the initial page load.
746//
747// Returns String version or undefined.
748function findVersion() {
749  return $('meta').filter(function() {
750    var name = $(this).attr('http-equiv')
751    return name && name.toUpperCase() === 'X-PJAX-VERSION'
752  }).attr('content')
753}
754
755// Install pjax functions on $.pjax to enable pushState behavior.
756//
757// Does nothing if already enabled.
758//
759// Examples
760//
761//     $.pjax.enable()
762//
763// Returns nothing.
764function enable() {
765  $.fn.pjax = fnPjax
766  $.pjax = pjax
767  $.pjax.enable = $.noop
768  $.pjax.disable = disable
769  $.pjax.click = handleClick
770  $.pjax.submit = handleSubmit
771  $.pjax.reload = pjaxReload
772  $.pjax.defaults = {
773    timeout: 1050,
774    push: true,
775    replace: false,
776    type: 'GET',
777    dataType: 'html',
778    scrollTo: 0,
779    maxCacheLength: 20,
780    version: findVersion
781  }
782  $(window).on('popstate.pjax', onPjaxPopstate)
783}
784
785// Disable pushState behavior.
786//
787// This is the case when a browser doesn't support pushState. It is
788// sometimes useful to disable pushState for debugging on a modern
789// browser.
790//
791// Examples
792//
793//     $.pjax.disable()
794//
795// Returns nothing.
796function disable() {
797  $.fn.pjax = function() { return this }
798  $.pjax = fallbackPjax
799  $.pjax.enable = enable
800  $.pjax.disable = $.noop
801  $.pjax.click = $.noop
802  $.pjax.submit = $.noop
803  $.pjax.reload = function() { window.location.reload() }
804
805  $(window).off('popstate.pjax', onPjaxPopstate)
806}
807
808
809// Add the state property to jQuery's event object so we can use it in
810// $(window).bind('popstate')
811if ( $.inArray('state', $.event.props) < 0 )
812  $.event.props.push('state')
813
814 // Is pjax supported by this browser?
815$.support.pjax =
816  window.history && window.history.pushState && window.history.replaceState &&
817  // pushState isn't reliable on iOS until 5.
818  !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]|WebApps\/.+CFNetwork)/)
819
820$.support.pjax ? enable() : disable()
821
822})(jQuery);
823