├── .editorconfig ├── .scrutinizer.yml ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── instantclick.js ├── phpunit.xml.dist ├── src └── Middleware │ └── FilterIfInstantClick.php └── tests ├── FilterIfInstantClickTest.php └── fixtures ├── pageWithTitle.html └── pageWithoutTitle.html /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | filter: 2 | excluded_paths: [tests/*] 3 | 4 | checks: 5 | php: 6 | remove_extra_empty_lines: true 7 | remove_php_closing_tag: true 8 | remove_trailing_whitespace: true 9 | fix_use_statements: 10 | remove_unused: true 11 | preserve_multiple: false 12 | preserve_blanklines: true 13 | order_alphabetically: true 14 | fix_php_opening_tag: true 15 | fix_linefeed: true 16 | fix_line_ending: true 17 | fix_identation_4spaces: true 18 | fix_doc_comments: true 19 | 20 | build: 21 | environment: 22 | php: '5.6.9' 23 | 24 | 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.5 5 | - 5.6 6 | - 7.0 7 | 8 | env: 9 | matrix: 10 | - COMPOSER_FLAGS="--prefer-lowest" 11 | - COMPOSER_FLAGS="" 12 | 13 | before_script: 14 | - travis_retry composer self-update 15 | - travis_retry composer update ${COMPOSER_FLAGS} --no-interaction --prefer-source 16 | 17 | script: 18 | - phpunit --coverage-text --coverage-clover=coverage.clover 19 | 20 | after_script: 21 | - php vendor/bin/ocular code-coverage:upload --format=php-clover coverage.clover 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All Notable changes to `Laravel-InstantClick` will be documented in this file 4 | 5 | ## 1.0.0 - 2016-2-9 6 | - Initial release 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/DiaaFares/Laravel-InstantClick). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ phpunit 29 | ``` 30 | 31 | 32 | **Happy coding**! 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Diaa Fares 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # An InstantClick middleware for Laravel 5 2 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/devmatic/laravel-instantclick.svg?style=flat-square)](https://packagist.org/packages/devmatic/laravel-instantclick) 3 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 4 | [![Build Status](https://img.shields.io/travis/devmatic/laravel-instantclick/master.svg?style=flat-square)](https://travis-ci.org/devmatic/laravel-instantclick) 5 | [![SensioLabsInsight](https://img.shields.io/sensiolabs/i/5ea5f3e8-e5fd-43b6-8b89-7c6845868eee.svg?style=flat-square)](https://insight.sensiolabs.com/projects/4c4ada94-d590-4c27-9118-dad4b5bebe73) 6 | [![Quality Score](https://img.shields.io/scrutinizer/g/diaafares/laravel-instantclick.svg?style=flat-square)](https://scrutinizer-ci.com/g/devmatic/laravel-instantclick) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/devmatic/laravel-instantclick.svg?style=flat-square)](https://packagist.org/packages/devmatic/laravel-instantclick) 8 | 9 | [InstantClick](https://github.com/dieulot/instantclick) is a plugin that makes following links in your website instant by leverages ajax to speed up the loading time of your pages. 10 | 11 | InstantClick uses pushState and Ajax (a combo known as pjax), replacing only the body and the title in the head. 12 | 13 | Devmatic is a web development company aims to make developers life easier. You’ll find an overview of all our projects on our [website](http://devmatic.co). 14 | 15 | # Ajax brings two nice benefits in and of itself: 16 | - Your browser doesn’t have to throw and recompile scripts and styles on each page change anymore. 17 | - You don’t get a white flash while your browser is waiting for a page to display, making your website feel faster. 18 | 19 | This package provides a middleware that can return the response that this plugin expects. 20 | 21 | ## Video Tutorial & Overview 22 | [![IMAGE ALT TEXT HERE](http://img.youtube.com/vi/IGv8dzD5rQA/0.jpg)](http://www.youtube.com/watch?v=IGv8dzD5rQA) 23 | 24 | ## Installation & Usage 25 | 26 | - You can install the package via composer: 27 | ``` bash 28 | $ composer require devmatic/laravel-instantclick 29 | ``` 30 | 31 | - Next you must add the `\Devmatic\InstantClick\Middleware\FilterIfInstantClick`-middleware to the kernel. 32 | ```php 33 | // app/Http/Kernel.php 34 | 35 | ... 36 | protected $middleware = [ 37 | ... 38 | \Devmatic\InstantClick\Middleware\FilterIfInstantClick::class, 39 | ]; 40 | ``` 41 | - **Copy the included instantclick.js** to your proper public asset folder then include it at your layout file like this: 42 | ```html 43 | 44 | 45 | ``` 46 | 47 | - Please refer to [InstantClient documentation](http://instantclick.io/documentation) to know more about InstantClient options and how it works. 48 | 49 | 50 | ## Important Note 51 | please use the included instantclick.js file because I modify it by adding $xhr.setRequestHeader(‘X-INSTANTCLICK’, true) to give the middleware the ability to identify InstantClient requests and give the proper response to it. 52 | 53 | 54 | ## How it Works 55 | 56 | The provided middleware provides the behaviour that the Instant Click plugin expects of the server: 57 | 58 | > An X-INSTANTCLICK request header is set to differentiate a InstantClick request from normal XHR requests. 59 | > In this case, if the request is InstantClick, we skip the layout html and just render the inner 60 | > contents of the body. 61 | 62 | ## Change log 63 | 64 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 65 | 66 | ## Testing 67 | 68 | ``` bash 69 | $ composer test 70 | ``` 71 | 72 | ## Contributing 73 | 74 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 75 | 76 | ## Credits 77 | 78 | - [Diaa Fares](https://github.com/DiaaFares) 79 | - [All Contributors](../../contributors) 80 | 81 | The middleware in this package was originally written by [Freek Van der Herten](https://github.com/freekmurze) for return the response that Pjax jquery plugin expects, I edit his middleware and InstantClick plugin to make it work for Laravel. 82 | His original code can be found [in this repo on GitHub](https://github.com/spatie/laravel-pjax). 83 | 84 | 85 | ## License 86 | 87 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 88 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devmatic/laravel-instantclick", 3 | "description": "An InstantClick middleware for Laravel 5", 4 | "keywords": [ 5 | "Devmatic", 6 | "diaafares", 7 | "Laravel-InstantClick", 8 | "instantclick", 9 | "laravel" 10 | ], 11 | "homepage": "https://github.com/devmatic/laravel-instantclick", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Diaa Fares", 16 | "email": "diaafares@gmail.com", 17 | "homepage": "https://github.com/Devmatic/", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "php" : "^5.5.0|^7.0", 23 | "illuminate/support": "^5.1", 24 | "illuminate/http": "^5.1", 25 | "symfony/dom-crawler": "^2.7|^3.0", 26 | "symfony/css-selector": "^2.7|^3.0" 27 | }, 28 | "require-dev": { 29 | "phpunit/phpunit" : "4.*", 30 | "scrutinizer/ocular": "~1.1" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Devmatic\\InstantClick\\": "src" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Devmatic\\InstantClick\\Test\\": "tests" 40 | } 41 | }, 42 | "scripts": { 43 | "test": "phpunit" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /instantclick.js: -------------------------------------------------------------------------------- 1 | /* InstantClick 3.1.0 | (C) 2014 Alexandre Dieulot | http://instantclick.io/license */ 2 | 3 | var InstantClick = function(document, location) { 4 | // Internal variables 5 | var $ua = navigator.userAgent, 6 | $isChromeForIOS = $ua.indexOf(' CriOS/') > -1, 7 | $hasTouch = 'createTouch' in document, 8 | $currentLocationWithoutHash, 9 | $urlToPreload, 10 | $preloadTimer, 11 | $lastTouchTimestamp, 12 | 13 | // Preloading-related variables 14 | $history = {}, 15 | $xhr, 16 | $url = false, 17 | $title = false, 18 | $mustRedirect = false, 19 | $body = false, 20 | $timing = {}, 21 | $isPreloading = false, 22 | $isWaitingForCompletion = false, 23 | $trackedAssets = [], 24 | 25 | // Variables defined by public functions 26 | $useWhitelist, 27 | $preloadOnMousedown, 28 | $delayBeforePreload, 29 | $eventsCallbacks = { 30 | fetch: [], 31 | receive: [], 32 | wait: [], 33 | change: [] 34 | } 35 | 36 | 37 | ////////// HELPERS ////////// 38 | 39 | 40 | function removeHash(url) { 41 | var index = url.indexOf('#') 42 | if (index < 0) { 43 | return url 44 | } 45 | return url.substr(0, index) 46 | } 47 | 48 | function getLinkTarget(target) { 49 | while (target && target.nodeName != 'A') { 50 | target = target.parentNode 51 | } 52 | return target 53 | } 54 | 55 | function isBlacklisted(elem) { 56 | do { 57 | if (!elem.hasAttribute) { // Parent of 58 | break 59 | } 60 | if (elem.hasAttribute('data-instant')) { 61 | return false 62 | } 63 | if (elem.hasAttribute('data-no-instant')) { 64 | return true 65 | } 66 | } 67 | while (elem = elem.parentNode); 68 | return false 69 | } 70 | 71 | function isWhitelisted(elem) { 72 | do { 73 | if (!elem.hasAttribute) { // Parent of 74 | break 75 | } 76 | if (elem.hasAttribute('data-no-instant')) { 77 | return false 78 | } 79 | if (elem.hasAttribute('data-instant')) { 80 | return true 81 | } 82 | } 83 | while (elem = elem.parentNode); 84 | return false 85 | } 86 | 87 | function isPreloadable(a) { 88 | var domain = location.protocol + '//' + location.host 89 | 90 | if (a.target // target="_blank" etc. 91 | || a.hasAttribute('download') 92 | || a.href.indexOf(domain + '/') != 0 // Another domain, or no href attribute 93 | || (a.href.indexOf('#') > -1 94 | && removeHash(a.href) == $currentLocationWithoutHash) // Anchor 95 | || ($useWhitelist 96 | ? !isWhitelisted(a) 97 | : isBlacklisted(a)) 98 | ) { 99 | return false 100 | } 101 | return true 102 | } 103 | 104 | function triggerPageEvent(eventType, arg1, arg2, arg3) { 105 | var returnValue = false 106 | for (var i = 0; i < $eventsCallbacks[eventType].length; i++) { 107 | if (eventType == 'receive') { 108 | var altered = $eventsCallbacks[eventType][i](arg1, arg2, arg3) 109 | if (altered) { 110 | /* Update args for the next iteration of the loop. */ 111 | if ('body' in altered) { 112 | arg2 = altered.body 113 | } 114 | if ('title' in altered) { 115 | arg3 = altered.title 116 | } 117 | 118 | returnValue = altered 119 | } 120 | } 121 | else { 122 | $eventsCallbacks[eventType][i](arg1, arg2, arg3) 123 | } 124 | } 125 | return returnValue 126 | } 127 | 128 | function changePage(title, body, newUrl, scrollY) { 129 | document.documentElement.replaceChild(body, document.body) 130 | /* We cannot just use `document.body = doc.body`, it causes Safari (tested 131 | 5.1, 6.0 and Mobile 7.0) to execute script tags directly. 132 | */ 133 | 134 | if (newUrl) { 135 | history.pushState(null, null, newUrl) 136 | 137 | var hashIndex = newUrl.indexOf('#'), 138 | hashElem = hashIndex > -1 139 | && document.getElementById(newUrl.substr(hashIndex + 1)), 140 | offset = 0 141 | 142 | if (hashElem) { 143 | while (hashElem.offsetParent) { 144 | offset += hashElem.offsetTop 145 | 146 | hashElem = hashElem.offsetParent 147 | } 148 | } 149 | scrollTo(0, offset) 150 | 151 | $currentLocationWithoutHash = removeHash(newUrl) 152 | } 153 | else { 154 | scrollTo(0, scrollY) 155 | } 156 | 157 | if ($isChromeForIOS && document.title == title) { 158 | /* Chrome for iOS: 159 | * 160 | * 1. Removes title on pushState, so the title needs to be set after. 161 | * 162 | * 2. Will not set the title if it’s identical when trimmed, so 163 | * appending a space won't do, but a non-breaking space works. 164 | */ 165 | document.title = title + String.fromCharCode(160) 166 | } 167 | else { 168 | document.title = title 169 | } 170 | 171 | instantanize() 172 | bar.done() 173 | triggerPageEvent('change', false) 174 | 175 | // Real event, useful for combining userscripts, but only for that so it’s undocumented. 176 | var userscriptEvent = document.createEvent('HTMLEvents') 177 | userscriptEvent.initEvent('instantclick:newpage', true, true) 178 | dispatchEvent(userscriptEvent) 179 | } 180 | 181 | function setPreloadingAsHalted() { 182 | $isPreloading = false 183 | $isWaitingForCompletion = false 184 | } 185 | 186 | function removeNoscriptTags(html) { 187 | /* Must be done on text, not on a node's innerHTML, otherwise strange 188 | * things happen with implicitly closed elements (see the Noscript test). 189 | */ 190 | return html.replace(//gi, '') 191 | } 192 | 193 | 194 | ////////// EVENT HANDLERS ////////// 195 | 196 | 197 | function mousedown(e) { 198 | if ($lastTouchTimestamp > (+new Date - 500)) { 199 | return // Otherwise, click doesn’t fire 200 | } 201 | 202 | var a = getLinkTarget(e.target) 203 | 204 | if (!a || !isPreloadable(a)) { 205 | return 206 | } 207 | 208 | preload(a.href) 209 | } 210 | 211 | function mouseover(e) { 212 | if ($lastTouchTimestamp > (+new Date - 500)) { 213 | return // Otherwise, click doesn’t fire 214 | } 215 | 216 | var a = getLinkTarget(e.target) 217 | 218 | if (!a || !isPreloadable(a)) { 219 | return 220 | } 221 | 222 | a.addEventListener('mouseout', mouseout) 223 | 224 | if (!$delayBeforePreload) { 225 | preload(a.href) 226 | } 227 | else { 228 | $urlToPreload = a.href 229 | $preloadTimer = setTimeout(preload, $delayBeforePreload) 230 | } 231 | } 232 | 233 | function touchstart(e) { 234 | $lastTouchTimestamp = +new Date 235 | 236 | var a = getLinkTarget(e.target) 237 | 238 | if (!a || !isPreloadable(a)) { 239 | return 240 | } 241 | 242 | if ($preloadOnMousedown) { 243 | a.removeEventListener('mousedown', mousedown) 244 | } 245 | else { 246 | a.removeEventListener('mouseover', mouseover) 247 | } 248 | preload(a.href) 249 | } 250 | 251 | function click(e) { 252 | var a = getLinkTarget(e.target) 253 | 254 | if (!a || !isPreloadable(a)) { 255 | return 256 | } 257 | 258 | if (e.which > 1 || e.metaKey || e.ctrlKey) { // Opening in new tab 259 | return 260 | } 261 | e.preventDefault() 262 | display(a.href) 263 | } 264 | 265 | function mouseout() { 266 | if ($preloadTimer) { 267 | clearTimeout($preloadTimer) 268 | $preloadTimer = false 269 | return 270 | } 271 | 272 | if (!$isPreloading || $isWaitingForCompletion) { 273 | return 274 | } 275 | $xhr.abort() 276 | setPreloadingAsHalted() 277 | } 278 | 279 | function readystatechange() { 280 | if ($xhr.readyState < 4) { 281 | return 282 | } 283 | if ($xhr.status == 0) { 284 | /* Request aborted */ 285 | return 286 | } 287 | 288 | $timing.ready = +new Date - $timing.start 289 | 290 | if ($xhr.getResponseHeader('Content-Type').match(/\/(x|ht|xht)ml/)) { 291 | var doc = document.implementation.createHTMLDocument('') 292 | doc.documentElement.innerHTML = removeNoscriptTags($xhr.responseText) 293 | $title = doc.title 294 | $body = doc.body 295 | 296 | var alteredOnReceive = triggerPageEvent('receive', $url, $body, $title) 297 | if (alteredOnReceive) { 298 | if ('body' in alteredOnReceive) { 299 | $body = alteredOnReceive.body 300 | } 301 | if ('title' in alteredOnReceive) { 302 | $title = alteredOnReceive.title 303 | } 304 | } 305 | 306 | var urlWithoutHash = removeHash($url) 307 | $history[urlWithoutHash] = { 308 | body: $body, 309 | title: $title, 310 | scrollY: urlWithoutHash in $history ? $history[urlWithoutHash].scrollY : 0 311 | } 312 | 313 | var elems = doc.head.children, 314 | found = 0, 315 | elem, 316 | data 317 | 318 | for (var i = elems.length - 1; i >= 0; i--) { 319 | elem = elems[i] 320 | if (elem.hasAttribute('data-instant-track')) { 321 | data = elem.getAttribute('href') || elem.getAttribute('src') || elem.innerHTML 322 | for (var j = $trackedAssets.length - 1; j >= 0; j--) { 323 | if ($trackedAssets[j] == data) { 324 | found++ 325 | } 326 | } 327 | } 328 | } 329 | if (found != $trackedAssets.length) { 330 | $mustRedirect = true // Assets have changed 331 | } 332 | } 333 | else { 334 | $mustRedirect = true // Not an HTML document 335 | } 336 | 337 | if ($isWaitingForCompletion) { 338 | $isWaitingForCompletion = false 339 | display($url) 340 | } 341 | } 342 | 343 | 344 | ////////// MAIN FUNCTIONS ////////// 345 | 346 | 347 | function instantanize(isInitializing) { 348 | document.body.addEventListener('touchstart', touchstart, true) 349 | if ($preloadOnMousedown) { 350 | document.body.addEventListener('mousedown', mousedown, true) 351 | } 352 | else { 353 | document.body.addEventListener('mouseover', mouseover, true) 354 | } 355 | document.body.addEventListener('click', click, true) 356 | 357 | if (!isInitializing) { 358 | var scripts = document.body.getElementsByTagName('script'), 359 | script, 360 | copy, 361 | parentNode, 362 | nextSibling 363 | 364 | for (i = 0, j = scripts.length; i < j; i++) { 365 | script = scripts[i] 366 | if (script.hasAttribute('data-no-instant')) { 367 | continue 368 | } 369 | copy = document.createElement('script') 370 | if (script.src) { 371 | copy.src = script.src 372 | } 373 | if (script.innerHTML) { 374 | copy.innerHTML = script.innerHTML 375 | } 376 | parentNode = script.parentNode 377 | nextSibling = script.nextSibling 378 | parentNode.removeChild(script) 379 | parentNode.insertBefore(copy, nextSibling) 380 | } 381 | } 382 | } 383 | 384 | function preload(url) { 385 | if (!$preloadOnMousedown 386 | && 'display' in $timing 387 | && +new Date - ($timing.start + $timing.display) < 100) { 388 | /* After a page is displayed, if the user's cursor happens to be above 389 | a link a mouseover event will be in most browsers triggered 390 | automatically, and in other browsers it will be triggered when the 391 | user moves his mouse by 1px. 392 | 393 | Here are the behavior I noticed, all on Windows: 394 | - Safari 5.1: auto-triggers after 0 ms 395 | - IE 11: auto-triggers after 30-80 ms (depends on page's size?) 396 | - Firefox: auto-triggers after 10 ms 397 | - Opera 18: auto-triggers after 10 ms 398 | 399 | - Chrome: triggers when cursor moved 400 | - Opera 12.16: triggers when cursor moved 401 | 402 | To remedy to this, we do not start preloading if last display 403 | occurred less than 100 ms ago. If they happen to click on the link, 404 | they will be redirected. 405 | */ 406 | 407 | return 408 | } 409 | if ($preloadTimer) { 410 | clearTimeout($preloadTimer) 411 | $preloadTimer = false 412 | } 413 | 414 | if (!url) { 415 | url = $urlToPreload 416 | } 417 | 418 | if ($isPreloading && (url == $url || $isWaitingForCompletion)) { 419 | return 420 | } 421 | $isPreloading = true 422 | $isWaitingForCompletion = false 423 | 424 | $url = url 425 | $body = false 426 | $mustRedirect = false 427 | $timing = { 428 | start: +new Date 429 | } 430 | triggerPageEvent('fetch') 431 | $xhr.open('GET', url) 432 | //$xhr.setRequestHeader('X-InstantClick-Container', '#instantclick-container') 433 | $xhr.setRequestHeader('X-INSTANTCLICK', true) 434 | 435 | $xhr.send() 436 | } 437 | 438 | function display(url) { 439 | if (!('display' in $timing)) { 440 | $timing.display = +new Date - $timing.start 441 | } 442 | if ($preloadTimer || !$isPreloading) { 443 | /* $preloadTimer: 444 | Happens when there’s a delay before preloading and that delay 445 | hasn't expired (preloading didn't kick in). 446 | 447 | !$isPreloading: 448 | A link has been clicked, and preloading hasn’t been initiated. 449 | It happens with touch devices when a user taps *near* the link, 450 | Safari/Chrome will trigger mousedown, mouseover, click (and others), 451 | but when that happens we ignore mousedown/mouseover (otherwise click 452 | doesn’t fire). Maybe there’s a way to make the click event fire, but 453 | that’s not worth it as mousedown/over happen just 1ms before click 454 | in this situation. 455 | 456 | It also happens when a user uses his keyboard to navigate (with Tab 457 | and Return), and possibly in other non-mainstream ways to navigate 458 | a website. 459 | */ 460 | 461 | if ($preloadTimer && $url && $url != url) { 462 | /* Happens when the user clicks on a link before preloading 463 | kicks in while another link is already preloading. 464 | */ 465 | 466 | location.href = url 467 | return 468 | } 469 | 470 | preload(url) 471 | bar.start(0, true) 472 | triggerPageEvent('wait') 473 | $isWaitingForCompletion = true // Must be set *after* calling `preload` 474 | return 475 | } 476 | if ($isWaitingForCompletion) { 477 | /* The user clicked on a link while a page was preloading. Either on 478 | the same link or on another link. If it's the same link something 479 | might have gone wrong (or he could have double clicked, we don’t 480 | handle that case), so we send him to the page without pjax. 481 | If it's another link, it hasn't been preloaded, so we redirect the 482 | user to it. 483 | */ 484 | location.href = url 485 | return 486 | } 487 | if ($mustRedirect) { 488 | location.href = $url 489 | return 490 | } 491 | if (!$body) { 492 | bar.start(0, true) 493 | triggerPageEvent('wait') 494 | $isWaitingForCompletion = true 495 | return 496 | } 497 | $history[$currentLocationWithoutHash].scrollY = pageYOffset 498 | setPreloadingAsHalted() 499 | changePage($title, $body, $url) 500 | } 501 | 502 | 503 | ////////// PROGRESS BAR FUNCTIONS ////////// 504 | 505 | 506 | var bar = function() { 507 | var $barContainer, 508 | $barElement, 509 | $barTransformProperty, 510 | $barProgress, 511 | $barTimer 512 | 513 | function init() { 514 | $barContainer = document.createElement('div') 515 | $barContainer.id = 'instantclick' 516 | $barElement = document.createElement('div') 517 | $barElement.id = 'instantclick-bar' 518 | $barElement.className = 'instantclick-bar' 519 | $barContainer.appendChild($barElement) 520 | 521 | var vendors = ['Webkit', 'Moz', 'O'] 522 | 523 | $barTransformProperty = 'transform' 524 | if (!($barTransformProperty in $barElement.style)) { 525 | for (var i = 0; i < 3; i++) { 526 | if (vendors[i] + 'Transform' in $barElement.style) { 527 | $barTransformProperty = vendors[i] + 'Transform' 528 | } 529 | } 530 | } 531 | 532 | var transitionProperty = 'transition' 533 | if (!(transitionProperty in $barElement.style)) { 534 | for (var i = 0; i < 3; i++) { 535 | if (vendors[i] + 'Transition' in $barElement.style) { 536 | transitionProperty = '-' + vendors[i].toLowerCase() + '-' + transitionProperty 537 | } 538 | } 539 | } 540 | 541 | var style = document.createElement('style') 542 | style.innerHTML = '#instantclick{position:' + ($hasTouch ? 'absolute' : 'fixed') + ';top:0;left:0;width:100%;pointer-events:none;z-index:2147483647;' + transitionProperty + ':opacity .25s .1s}' 543 | + '.instantclick-bar{background:#29d;width:100%;margin-left:-100%;height:2px;' + transitionProperty + ':all .25s}' 544 | /* We set the bar's background in `.instantclick-bar` so that it can be 545 | overriden in CSS with `#instantclick-bar`, as IDs have higher priority. 546 | */ 547 | document.head.appendChild(style) 548 | 549 | if ($hasTouch) { 550 | updatePositionAndScale() 551 | addEventListener('resize', updatePositionAndScale) 552 | addEventListener('scroll', updatePositionAndScale) 553 | } 554 | 555 | } 556 | 557 | function start(at, jump) { 558 | $barProgress = at 559 | if (document.getElementById($barContainer.id)) { 560 | document.body.removeChild($barContainer) 561 | } 562 | $barContainer.style.opacity = '1' 563 | if (document.getElementById($barContainer.id)) { 564 | document.body.removeChild($barContainer) 565 | /* So there's no CSS animation if already done once and it goes from 1 to 0 */ 566 | } 567 | update() 568 | if (jump) { 569 | setTimeout(jumpStart, 0) 570 | /* Must be done in a timer, otherwise the CSS animation doesn't happen. */ 571 | } 572 | clearTimeout($barTimer) 573 | $barTimer = setTimeout(inc, 500) 574 | } 575 | 576 | function jumpStart() { 577 | $barProgress = 10 578 | update() 579 | } 580 | 581 | function inc() { 582 | $barProgress += 1 + (Math.random() * 2) 583 | if ($barProgress >= 98) { 584 | $barProgress = 98 585 | } 586 | else { 587 | $barTimer = setTimeout(inc, 500) 588 | } 589 | update() 590 | } 591 | 592 | function update() { 593 | $barElement.style[$barTransformProperty] = 'translate(' + $barProgress + '%)' 594 | if (!document.getElementById($barContainer.id)) { 595 | document.body.appendChild($barContainer) 596 | } 597 | } 598 | 599 | function done() { 600 | if (document.getElementById($barContainer.id)) { 601 | clearTimeout($barTimer) 602 | $barProgress = 100 603 | update() 604 | $barContainer.style.opacity = '0' 605 | /* If you're debugging, setting this to 0.5 is handy. */ 606 | return 607 | } 608 | 609 | /* The bar container hasn't been appended: It's a new page. */ 610 | start($barProgress == 100 ? 0 : $barProgress) 611 | /* $barProgress is 100 on popstate, usually. */ 612 | setTimeout(done, 0) 613 | /* Must be done in a timer, otherwise the CSS animation doesn't happen. */ 614 | } 615 | 616 | function updatePositionAndScale() { 617 | /* Adapted from code by Sam Stephenson and Mislav Marohnić 618 | http://signalvnoise.com/posts/2407 619 | */ 620 | 621 | $barContainer.style.left = pageXOffset + 'px' 622 | $barContainer.style.width = innerWidth + 'px' 623 | $barContainer.style.top = pageYOffset + 'px' 624 | 625 | var landscape = 'orientation' in window && Math.abs(orientation) == 90, 626 | scaleY = innerWidth / screen[landscape ? 'height' : 'width'] * 2 627 | /* We multiply the size by 2 because the progress bar is harder 628 | to notice on a mobile device. 629 | */ 630 | $barContainer.style[$barTransformProperty] = 'scaleY(' + scaleY + ')' 631 | } 632 | 633 | return { 634 | init: init, 635 | start: start, 636 | done: done 637 | } 638 | }() 639 | 640 | 641 | ////////// PUBLIC VARIABLE AND FUNCTIONS ////////// 642 | 643 | var supported = 'pushState' in history 644 | && (!$ua.match('Android') || $ua.match('Chrome/')) 645 | && location.protocol != "file:" 646 | 647 | /* The state of Android's AOSP browsers: 648 | 649 | 2.3.7: pushState appears to work correctly, but 650 | `doc.documentElement.innerHTML = body` is buggy. 651 | See details here: http://stackoverflow.com/q/21918564 652 | Not an issue anymore, but it may fail where 3.0 do, this needs 653 | testing again. 654 | 655 | 3.0: pushState appears to work correctly (though the URL bar is only 656 | updated on focus), but 657 | `document.documentElement.replaceChild(doc.body, document.body)` 658 | throws DOMException: WRONG_DOCUMENT_ERR. 659 | 660 | 4.0.2: Doesn't support pushState. 661 | 662 | 4.0.4, 663 | 4.1.1, 664 | 4.2, 665 | 4.3: pushState is here, but it doesn't update the URL bar. 666 | (Great logic there.) 667 | 668 | 4.4: Works correctly. Claims to be 'Chrome/30.0.0.0'. 669 | 670 | All androids tested with Android SDK's Emulator. 671 | Version numbers are from the browser's user agent. 672 | 673 | Because of this mess, the only whitelisted browser on Android is Chrome. 674 | */ 675 | 676 | function init() { 677 | if ($currentLocationWithoutHash) { 678 | /* Already initialized */ 679 | return 680 | } 681 | if (!supported) { 682 | triggerPageEvent('change', true) 683 | return 684 | } 685 | for (var i = arguments.length - 1; i >= 0; i--) { 686 | var arg = arguments[i] 687 | if (arg === true) { 688 | $useWhitelist = true 689 | } 690 | else if (arg == 'mousedown') { 691 | $preloadOnMousedown = true 692 | } 693 | else if (typeof arg == 'number') { 694 | $delayBeforePreload = arg 695 | } 696 | } 697 | $currentLocationWithoutHash = removeHash(location.href) 698 | $history[$currentLocationWithoutHash] = { 699 | body: document.body, 700 | title: document.title, 701 | scrollY: pageYOffset 702 | } 703 | 704 | var elems = document.head.children, 705 | elem, 706 | data 707 | for (var i = elems.length - 1; i >= 0; i--) { 708 | elem = elems[i] 709 | if (elem.hasAttribute('data-instant-track')) { 710 | data = elem.getAttribute('href') || elem.getAttribute('src') || elem.innerHTML 711 | /* We can't use just `elem.href` and `elem.src` because we can't 712 | retrieve `href`s and `src`s from the Ajax response. 713 | */ 714 | $trackedAssets.push(data) 715 | } 716 | } 717 | 718 | $xhr = new XMLHttpRequest() 719 | $xhr.addEventListener('readystatechange', readystatechange) 720 | 721 | 722 | instantanize(true) 723 | 724 | bar.init() 725 | 726 | triggerPageEvent('change', true) 727 | 728 | addEventListener('popstate', function() { 729 | var loc = removeHash(location.href) 730 | if (loc == $currentLocationWithoutHash) { 731 | return 732 | } 733 | 734 | if (!(loc in $history)) { 735 | location.href = location.href 736 | /* Reloads the page while using cache for scripts, styles and images, 737 | unlike `location.reload()` */ 738 | return 739 | } 740 | 741 | $history[$currentLocationWithoutHash].scrollY = pageYOffset 742 | $currentLocationWithoutHash = loc 743 | changePage($history[loc].title, $history[loc].body, false, $history[loc].scrollY) 744 | }) 745 | } 746 | 747 | function on(eventType, callback) { 748 | $eventsCallbacks[eventType].push(callback) 749 | } 750 | 751 | 752 | //////////////////// 753 | 754 | 755 | return { 756 | supported: supported, 757 | init: init, 758 | on: on 759 | } 760 | 761 | }(document, location); 762 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Middleware/FilterIfInstantClick.php: -------------------------------------------------------------------------------- 1 | isInstantClickRequest($request) || $response->isRedirection()) { 32 | return $response; 33 | } 34 | 35 | $this->filterResponse($response, $container = 'body') 36 | ->setResponseHeader($response); 37 | 38 | return $response; 39 | } 40 | 41 | /** 42 | * @param \Illuminate\Http\Response $response 43 | * @param string $container 44 | * 45 | * @return $this 46 | */ 47 | protected function filterResponse(Response $response, $container) 48 | { 49 | $crawler = $this->getCrawler($response); 50 | 51 | $response->setContent( 52 | $this->makeTitle($crawler) . 53 | $this->fetchContainer($crawler, $container) 54 | ); 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * @param \Symfony\Component\DomCrawler\Crawler $crawler 61 | * 62 | * @return null|string 63 | */ 64 | protected function makeTitle(Crawler $crawler) 65 | { 66 | $pageTitle = $crawler->filter('head > title'); 67 | 68 | if (!$pageTitle->count()) { 69 | return; 70 | } 71 | 72 | return "{$pageTitle->html()}"; 73 | } 74 | 75 | /** 76 | * @param \Symfony\Component\DomCrawler\Crawler $crawler 77 | * @param string $container 78 | * 79 | * @return string 80 | */ 81 | protected function fetchContainer(Crawler $crawler, $container) 82 | { 83 | $content = $crawler->filter($container); 84 | 85 | if (!$content->count()) { 86 | abort(422); 87 | } 88 | 89 | return $content->html(); 90 | } 91 | 92 | /** 93 | * Get the DomCrawler instance. 94 | * 95 | * @param \Illuminate\Http\Response $response 96 | * 97 | * @return \Symfony\Component\DomCrawler\Crawler 98 | */ 99 | protected function getCrawler(Response $response) 100 | { 101 | if ($this->crawler) { 102 | return $this->crawler; 103 | } 104 | 105 | return $this->crawler = new Crawler($response->getContent()); 106 | } 107 | 108 | 109 | /** 110 | * Determine if the request is the result of an InstantClick call. 111 | * 112 | * @return bool 113 | */ 114 | public function isInstantClickRequest(Request $request) 115 | { 116 | return $request->headers->get('X-INSTANTCLICK') == true; 117 | } 118 | 119 | /** 120 | * @param \Illuminate\Http\Response $response 121 | * 122 | * @return $this 123 | * @internal param \Illuminate\Http\Request $request 124 | * 125 | */ 126 | protected function setResponseHeader(Response $response) 127 | { 128 | $response->header('X-INSTANTCLICK', "true"); 129 | 130 | return $this; 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /tests/FilterIfInstantClickTest.php: -------------------------------------------------------------------------------- 1 | middleware = new FilterIfInstantClick(); 14 | } 15 | 16 | /** @test */ 17 | public function it_will_not_modify_a_non_instantclick_request() 18 | { 19 | $request = new Request(); 20 | 21 | $response = $this->middleware->handle($request, $this->getNext()); 22 | 23 | $this->assertFalse($this->isInstantClickReponse($response)); 24 | 25 | $this->assertEquals($this->getHtml(), $response->getContent()); 26 | } 27 | 28 | /** @test */ 29 | public function it_will_return_the_title_and_contents_of_the_container_for_instantclick_request() 30 | { 31 | $request = $this->addInstantClickHeaders(new Request()); 32 | 33 | $response = $this->middleware->handle($request, $this->getNext()); 34 | 35 | $this->assertTrue($this->isInstantClickReponse($response)); 36 | 37 | $this->assertEquals("InstantClick title\n
Content
\n ", $response->getContent()); 38 | } 39 | 40 | /** @test */ 41 | public function it_will_not_return_the_title_if_it_is_not_set() 42 | { 43 | $request = $this->addInstantClickHeaders(new Request()); 44 | 45 | $response = $this->middleware->handle($request, $this->getNext('pageWithoutTitle')); 46 | 47 | $this->assertTrue($this->isInstantClickReponse($response)); 48 | 49 | $this->assertEquals("\n
Content
\n ", $response->getContent()); 50 | } 51 | 52 | /** 53 | * @param \Symfony\Component\HttpFoundation\Response $response 54 | * 55 | * @return bool 56 | */ 57 | protected function isInstantClickReponse(Response $response) 58 | { 59 | return $response->headers->has('X-INSTANTCLICK'); 60 | } 61 | 62 | /** 63 | * @param \Illuminate\Http\Request $request 64 | * 65 | * @return \Illuminate\Http\Request 66 | */ 67 | protected function addInstantClickHeaders(Request $request) 68 | { 69 | $request->headers->set('X-INSTANTCLICK', true); 70 | 71 | return $request; 72 | } 73 | 74 | /** 75 | * @param string $pageName 76 | * 77 | * @return \Closure 78 | */ 79 | protected function getNext($pageName = 'pageWithTitle') 80 | { 81 | $html = $this->getHtml($pageName); 82 | 83 | $response = (new \Illuminate\Http\Response($html)); 84 | 85 | return function ($request) use ($response) { 86 | 87 | return $response; 88 | }; 89 | } 90 | 91 | /** 92 | * @param string $pageName 93 | * 94 | * @return string 95 | */ 96 | protected function getHtml($pageName = 'pageWithTitle') 97 | { 98 | return file_get_contents(__DIR__."/fixtures/{$pageName}.html"); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/fixtures/pageWithTitle.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | InstantClick title 5 | 6 | 7 | 8 |
Content
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/fixtures/pageWithoutTitle.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
Content
9 | 10 | 11 | 12 | --------------------------------------------------------------------------------