├── .gitignore ├── Contributing.md ├── LICENSE ├── ReadMe.md ├── bower.json ├── makefile ├── src ├── instantclick.js └── loading-indicator.js └── tests ├── anchors.html ├── index.php ├── instantclick.js.php ├── non-html.php ├── pages ├── alter-receive.html ├── anchor1.html ├── anchor2.html ├── child-n-blacklist.html ├── entities.html ├── event-delegation.html ├── index.html ├── no-title.html ├── non-html.html ├── noscript.html └── touch.html └── style.css /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Getting support 4 | 5 | There is no dedicated place for support at the moment. Use [Stack Overflow](http://stackoverflow.com/search?q=instantclick) for now. Having an official InstantClick forum is planned. 6 | 7 | ## Bugs 8 | 9 | ### Reporting a bug 10 | 11 | When reporting a bug, ideally you should provide a reduced test case: the minimal amount of code that makes the problem appear. 12 | 13 | If you don’t feel like doing this, it’s okay. A vague bug report is better than no bug report at all. 14 | 15 | ### Fixing a bug 16 | 17 | If you went the extra mile and fixed a bug, open a pull request. 18 | 19 | In doing so, please emulate the code’s conventions (no [unnecessary semicolons](http://mislav.uniqpath.com/2010/05/semicolons/), two spaces for tabs, etc.) and only commit the changes that are relevant to the fix (with [`git add -a `](http://stackoverflow.com/a/1085191/921889)). If you’ve already committed a “dirty” commit, use `git reset --soft HEAD~1` to [undo](http://stackoverflow.com/q/927358/921889) and start again. 20 | 21 | If you have trouble following those instructions, contribute your pull request anyway. A bugfix that can’t get easily merged is better than no bugfix. 22 | 23 | ## New features/enhancements 24 | 25 | Check out [already suggested enhancements](https://github.com/dieulot/instantclick/issues?q=label%3AEnhancement). If you have something else in mind open a new issue. 26 | 27 | Pull requests for new features/enhancements won’t get merged because the code is the tiniest part of a new feature. There’s ease of use, documentation, marketing and maintainability to take into account *before* coding anything. Delegating the programming of new features to the community would make things slower. 28 | 29 | If your idea is best represented by code than by words alone, you can still open a pull request (which will be closed) and link to it in an issue. 30 | 31 | ## Documentation 32 | 33 | If you’ve spotted a typo, a non-idiomatic sentence, an unclear sentence (or just anything that may hinder understanding) on the website, open an issue or a pull request in the [instantclick-website repo](https://github.com/dieulot/instantclick-website). 34 | 35 | 30% of InstantClick users don’t have English as their first language, if you see a word (or even a sentence) that can be replaced with a simpler one without losing meaning, please contribute it! 36 | 37 | Contributing to documentation for more substantial things is a pain (and will probably always be due to a lack of priority, I deem documentation critical enough that I plan to do all the big things (such as the onboarding process, for instance) by myself), so it’s totally okay if you don’t want to wrap your head around how to make a pull request. Just open an issue and dump all your texts there. Yes, it’s messy, but there’s no good alternative at the moment. 38 | 39 | Official translations of the documentation will be organized when InstantClick is close to being feature-complete, because the efforts to keep the documentation synchronized in multiple languages aren’t worth it for now. 40 | 41 | ### The contributing experience 42 | 43 | If something wasn’t clear for you (whether it is rationally justified or not) when you tried to contribute, please talk about it. Put it at the end of your issue, or open another issue for it. Removing friction in a process (here, contributing to the project) is useful; don’t be afraid to look like a dummy, you won’t. 44 | 45 | More generally, anything that you feel would improve the project is welcome, even if it’s very small and/or there’s no formal process to contribute it. 46 | 47 | ## About the pull requests and bugs languishing 48 | 49 | I have been slacking quite a bit this past year (as of February 2015) on this project. Sorry about that. I have an awful lot of things planned for InstantClick and all my todos/ideas (I have hundreds) were disseminated in multiple digital places, then I didn’t know where to start anymore. It’s all in my Trello now, I hope this will resolve the slacking problem. 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2017 Alexandre Dieulot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # [InstantClick](http://instantclick.io/) 2 | 3 | All the informations you need to use InstantClick are on the link above. This ReadMe’s purpose is about how to use and contribute to a development version of InstantClick. 4 | 5 | ## Tests 6 | 7 | Tests (in the `tests` folder) are PHP-generated HTML pages with which to check how InstantClick behaves on different browsers. That’s what I use before releasing a new version to make sure there are no obvious regressions. 8 | 9 | To access the suite of tests, run `php -S 127.0.0.1:8000` from the `tests` folder and head to [http://127.0.0.1:8000/](http://127.0.0.1:8000/). 10 | 11 | ## [Contributing](Contributing.md) 12 | 13 | See the `Contributing.md` file. 14 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "instantclick", 3 | "description": "InstantClick makes following links in your website instant.", 4 | "main": [ 5 | "./src/instantclick.js" 6 | ], 7 | "license": "MIT", 8 | "keywords": [ 9 | "pjax" 10 | ], 11 | "homepage": "http://instantclick.io/" 12 | } 13 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | all: 2 | @mkdir -p build 3 | @head -1 src/instantclick.js > build/min.head.js 4 | @cat src/instantclick.js src/loading-indicator.js > build/cat.js 5 | @curl --silent --data "output_info=compiled_code" --data-urlencode "js_code@build/cat.js" "http://closure-compiler.appspot.com/compile" -o build/min.code.js 6 | @cat build/min.head.js build/min.code.js > build/instantclick.min.js 7 | @rm build/cat.js build/min.head.js build/min.code.js 8 | @gzip build/instantclick.min.js 9 | @du -b build/instantclick.min.js.gz 10 | @gunzip build/instantclick.min.js.gz 11 | 12 | clean: 13 | @rm -r build 14 | -------------------------------------------------------------------------------- /src/instantclick.js: -------------------------------------------------------------------------------- 1 | /* InstantClick 3.1.0 | (C) 2014-2017 Alexandre Dieulot | http://instantclick.io/license */ 2 | 3 | var instantclick 4 | , InstantClick = instantclick = function(document, location, $userAgent) { 5 | // Internal variables 6 | var $currentLocationWithoutHash 7 | , $urlToPreload 8 | , $preloadTimer 9 | , $lastTouchTimestamp 10 | , $hasBeenInitialized 11 | , $touchEndedWithoutClickTimer 12 | , $lastUsedTimeoutId = 0 13 | 14 | // Preloading-related variables 15 | , $history = {} 16 | , $xhr 17 | , $url = false 18 | , $title = false 19 | , $isContentTypeNotHTML 20 | , $areTrackedElementsDifferent 21 | , $body = false 22 | , $lastDisplayTimestamp = 0 23 | , $isPreloading = false 24 | , $isWaitingForCompletion = false 25 | , $gotANetworkError = false 26 | , $trackedElementsData = [] 27 | 28 | // Variables defined by public functions 29 | , $preloadOnMousedown 30 | , $delayBeforePreload = 65 31 | , $eventsCallbacks = { 32 | preload: [], 33 | receive: [], 34 | wait: [], 35 | change: [], 36 | restore: [], 37 | exit: [] 38 | } 39 | , $timers = {} 40 | , $currentPageXhrs = [] 41 | , $windowEventListeners = {} 42 | , $delegatedEvents = {} 43 | 44 | 45 | ////////// POLYFILL ////////// 46 | 47 | 48 | // Needed for `addEvent` 49 | if (!Element.prototype.matches) { 50 | Element.prototype.matches = 51 | Element.prototype.webkitMatchesSelector || 52 | Element.prototype.msMatchesSelector || 53 | function (selector) { 54 | var matches = document.querySelectorAll(selector) 55 | for (var i = 0; i < matches.length; i++) { 56 | if (matches[i] == this) { 57 | return true 58 | } 59 | } 60 | return false 61 | } 62 | } 63 | 64 | 65 | ////////// HELPERS ////////// 66 | 67 | 68 | function removeHash(url) { 69 | var index = url.indexOf('#') 70 | if (index == -1) { 71 | return url 72 | } 73 | return url.substr(0, index) 74 | } 75 | 76 | function getParentLinkElement(element) { 77 | while (element && element.nodeName != 'A') { 78 | element = element.parentNode 79 | } 80 | // `element` will be null if no link element is found 81 | return element 82 | } 83 | 84 | function isBlacklisted(element) { 85 | do { 86 | if (!element.hasAttribute) { // Parent of 87 | break 88 | } 89 | if (element.hasAttribute('data-instant')) { 90 | return false 91 | } 92 | if (element.hasAttribute('data-no-instant')) { 93 | return true 94 | } 95 | } 96 | while (element = element.parentNode) 97 | return false 98 | } 99 | 100 | function isPreloadable(linkElement) { 101 | var domain = location.protocol + '//' + location.host 102 | 103 | if (linkElement.target // target="_blank" etc. 104 | || linkElement.hasAttribute('download') 105 | || linkElement.href.indexOf(domain + '/') != 0 // Another domain, or no href attribute 106 | || (linkElement.href.indexOf('#') > -1 107 | && removeHash(linkElement.href) == $currentLocationWithoutHash) // Anchor 108 | || isBlacklisted(linkElement) 109 | ) { 110 | return false 111 | } 112 | return true 113 | } 114 | 115 | function triggerPageEvent(eventType) { 116 | var argumentsToApply = Array.prototype.slice.call(arguments, 1) 117 | , returnValue = false 118 | for (var i = 0; i < $eventsCallbacks[eventType].length; i++) { 119 | if (eventType == 'receive') { 120 | var altered = $eventsCallbacks[eventType][i].apply(window, argumentsToApply) 121 | if (altered) { 122 | // Update arguments for the next iteration of the loop. 123 | if ('body' in altered) { 124 | argumentsToApply[1] = altered.body 125 | } 126 | if ('title' in altered) { 127 | argumentsToApply[2] = altered.title 128 | } 129 | 130 | returnValue = altered 131 | } 132 | } 133 | else { 134 | $eventsCallbacks[eventType][i].apply(window, argumentsToApply) 135 | } 136 | } 137 | return returnValue 138 | } 139 | 140 | function changePage(title, body, urlToPush, scrollPosition) { 141 | abortCurrentPageXhrs() 142 | 143 | document.documentElement.replaceChild(body, document.body) 144 | // We cannot just use `document.body = doc.body`, it causes Safari (tested 145 | // 5.1, 6.0 and Mobile 7.0) to execute script tags directly. 146 | 147 | document.title = title 148 | 149 | if (urlToPush) { 150 | addOrRemoveWindowEventListeners('remove') 151 | if (urlToPush != location.href) { 152 | history.pushState(null, null, urlToPush) 153 | 154 | if ($userAgent.indexOf(' CriOS/') > -1) { 155 | // Chrome for iOS: 156 | // 157 | // 1. Removes title in tab on pushState, so it needs to be set after. 158 | // 159 | // 2. Will not set the title if it's identical after trimming, so we 160 | // add a non-breaking space. 161 | if (document.title == title) { 162 | document.title = title + String.fromCharCode(160) 163 | } 164 | else { 165 | document.title = title 166 | } 167 | } 168 | } 169 | 170 | var hashIndex = urlToPush.indexOf('#') 171 | , offsetElement = hashIndex > -1 172 | && document.getElementById(urlToPush.substr(hashIndex + 1)) 173 | , offset = 0 174 | 175 | if (offsetElement) { 176 | while (offsetElement.offsetParent) { 177 | offset += offsetElement.offsetTop 178 | 179 | offsetElement = offsetElement.offsetParent 180 | } 181 | } 182 | if ('requestAnimationFrame' in window) { 183 | // Safari on macOS doesn't immediately visually change the page on 184 | // `document.documentElement.replaceChild`, so if `scrollTo` is called 185 | // without `requestAnimationFrame` it often scrolls before the page 186 | // is displayed. 187 | requestAnimationFrame(function() { 188 | scrollTo(0, offset) 189 | }) 190 | } 191 | else { 192 | scrollTo(0, offset) 193 | // Safari on macOS scrolls before the page is visually changed, but 194 | // adding `requestAnimationFrame` doesn't fix it in this case. 195 | } 196 | 197 | clearCurrentPageTimeouts() 198 | 199 | $currentLocationWithoutHash = removeHash(urlToPush) 200 | 201 | if ($currentLocationWithoutHash in $windowEventListeners) { 202 | $windowEventListeners[$currentLocationWithoutHash] = [] 203 | } 204 | 205 | $timers[$currentLocationWithoutHash] = {} 206 | 207 | applyScriptElements(function(element) { 208 | return !element.hasAttribute('data-instant-track') 209 | }) 210 | 211 | triggerPageEvent('change', false) 212 | } 213 | else { 214 | // On popstate, browsers scroll by themselves, but at least Firefox 215 | // scrolls BEFORE popstate is fired and thus before we can replace the 216 | // page. If the page before popstate is too short the user won't be 217 | // scrolled at the right position as a result. We need to scroll again. 218 | scrollTo(0, scrollPosition) 219 | 220 | // iOS's gesture to go back by swiping from the left edge of the screen 221 | // will start a preloading if the user touches a link, it needs to be 222 | // cancelled otherwise the page behind the touched link will be 223 | // displayed. 224 | $xhr.abort() 225 | setPreloadingAsHalted() 226 | 227 | applyScriptElements(function(element) { 228 | return element.hasAttribute('data-instant-restore') 229 | }) 230 | 231 | restoreTimers() 232 | 233 | triggerPageEvent('restore') 234 | } 235 | } 236 | 237 | function setPreloadingAsHalted() { 238 | $isPreloading = false 239 | $isWaitingForCompletion = false 240 | } 241 | 242 | function removeNoscriptTags(html) { 243 | // Must be done on text, not on a node's innerHTML, otherwise strange 244 | // things happen with implicitly closed elements (see the Noscript test). 245 | return html.replace(//gi, '') 246 | } 247 | 248 | function abortCurrentPageXhrs() { 249 | for (var i = 0; i < $currentPageXhrs.length; i++) { 250 | if (typeof $currentPageXhrs[i] == 'object' && 'abort' in $currentPageXhrs[i]) { 251 | $currentPageXhrs[i].instantclickAbort = true 252 | $currentPageXhrs[i].abort() 253 | } 254 | } 255 | $currentPageXhrs = [] 256 | } 257 | 258 | function clearCurrentPageTimeouts() { 259 | for (var i in $timers[$currentLocationWithoutHash]) { 260 | var timeout = $timers[$currentLocationWithoutHash][i] 261 | window.clearTimeout(timeout.realId) 262 | timeout.delayLeft = timeout.delay - +new Date + timeout.timestamp 263 | } 264 | } 265 | 266 | function restoreTimers() { 267 | for (var i in $timers[$currentLocationWithoutHash]) { 268 | if (!('delayLeft' in $timers[$currentLocationWithoutHash][i])) { 269 | continue 270 | } 271 | var args = [ 272 | $timers[$currentLocationWithoutHash][i].callback, 273 | $timers[$currentLocationWithoutHash][i].delayLeft 274 | ] 275 | for (var j = 0; j < $timers[$currentLocationWithoutHash][i].params.length; j++) { 276 | args.push($timers[$currentLocationWithoutHash][i].params[j]) 277 | } 278 | addTimer(args, $timers[$currentLocationWithoutHash][i].isRepeating, $timers[$currentLocationWithoutHash][i].delay) 279 | delete $timers[$currentLocationWithoutHash][i] 280 | } 281 | } 282 | 283 | function handleTouchendWithoutClick() { 284 | $xhr.abort() 285 | setPreloadingAsHalted() 286 | } 287 | 288 | function addOrRemoveWindowEventListeners(addOrRemove) { 289 | if ($currentLocationWithoutHash in $windowEventListeners) { 290 | for (var i = 0; i < $windowEventListeners[$currentLocationWithoutHash].length; i++) { 291 | window[addOrRemove + 'EventListener'].apply(window, $windowEventListeners[$currentLocationWithoutHash][i]) 292 | } 293 | } 294 | } 295 | 296 | function applyScriptElements(condition) { 297 | var scriptElementsInDOM = document.body.getElementsByTagName('script') 298 | , scriptElementsToCopy = [] 299 | , originalElement 300 | , copyElement 301 | , parentNode 302 | , nextSibling 303 | , i 304 | 305 | // `scriptElementsInDOM` will change during the copy of scripts if 306 | // a script add or delete script elements, so we need to put script 307 | // elements in an array to loop through them correctly. 308 | for (i = 0; i < scriptElementsInDOM.length; i++) { 309 | scriptElementsToCopy.push(scriptElementsInDOM[i]) 310 | } 311 | 312 | for (i = 0; i < scriptElementsToCopy.length; i++) { 313 | originalElement = scriptElementsToCopy[i] 314 | if (!originalElement) { // Might have disappeared, see previous comment 315 | continue 316 | } 317 | if (!condition(originalElement)) { 318 | continue 319 | } 320 | 321 | copyElement = document.createElement('script') 322 | for (var j = 0; j < originalElement.attributes.length; j++) { 323 | copyElement.setAttribute(originalElement.attributes[j].name, originalElement.attributes[j].value) 324 | } 325 | copyElement.textContent = originalElement.textContent 326 | 327 | parentNode = originalElement.parentNode 328 | nextSibling = originalElement.nextSibling 329 | parentNode.removeChild(originalElement) 330 | parentNode.insertBefore(copyElement, nextSibling) 331 | } 332 | } 333 | 334 | function addTrackedElements() { 335 | var trackedElements = document.querySelectorAll('[data-instant-track]') 336 | , element 337 | , elementData 338 | for (var i = 0; i < trackedElements.length; i++) { 339 | element = trackedElements[i] 340 | elementData = element.getAttribute('href') || element.getAttribute('src') || element.textContent 341 | // We can't use just `element.href` and `element.src` because we can't 342 | // retrieve `href`s and `src`s from the Ajax response. 343 | $trackedElementsData.push(elementData) 344 | } 345 | } 346 | 347 | function addTimer(args, isRepeating, realDelay) { 348 | var callback = args[0] 349 | , delay = args[1] 350 | , params = [].slice.call(args, 2) 351 | , timestamp = +new Date 352 | 353 | $lastUsedTimeoutId++ 354 | var id = $lastUsedTimeoutId 355 | 356 | var callbackModified 357 | if (isRepeating) { 358 | callbackModified = function(args2) { 359 | callback(args2) 360 | delete $timers[$currentLocationWithoutHash][id] 361 | args[0] = callback 362 | args[1] = delay 363 | addTimer(args, true) 364 | } 365 | } 366 | else { 367 | callbackModified = function(args2) { 368 | callback(args2) 369 | delete $timers[$currentLocationWithoutHash][id] 370 | } 371 | } 372 | 373 | args[0] = callbackModified 374 | if (realDelay != undefined) { 375 | timestamp += delay - realDelay 376 | delay = realDelay 377 | } 378 | var realId = window.setTimeout.apply(window, args) 379 | $timers[$currentLocationWithoutHash][id] = { 380 | realId: realId, 381 | timestamp: timestamp, 382 | callback: callback, 383 | delay: delay, 384 | params: params, 385 | isRepeating: isRepeating 386 | } 387 | return -id 388 | } 389 | 390 | 391 | ////////// EVENT LISTENERS ////////// 392 | 393 | 394 | function mousedownListener(event) { 395 | var linkElement = getParentLinkElement(event.target) 396 | 397 | if (!linkElement || !isPreloadable(linkElement)) { 398 | return 399 | } 400 | 401 | preload(linkElement.href) 402 | } 403 | 404 | function mouseoverListener(event) { 405 | if ($lastTouchTimestamp > (+new Date - 500)) { 406 | // On a touch device, if the content of the page change on mouseover 407 | // click is never fired and the user will need to tap a second time. 408 | // https://developer.apple.com/library/content/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html#//apple_ref/doc/uid/TP40006511-SW4 409 | // 410 | // Content change could happen in the `preload` event, so we stop there. 411 | return 412 | } 413 | 414 | if (+new Date - $lastDisplayTimestamp < 100) { 415 | // After a page is displayed, if the user's cursor happens to be above 416 | // a link a mouseover event will be in most browsers triggered 417 | // automatically, and in other browsers it will be triggered when the 418 | // user moves his mouse by 1px. 419 | // 420 | // Here are the behaviors I noticed, all on Windows: 421 | // - Safari 5.1: auto-triggers after 0 ms 422 | // - IE 11: auto-triggers after 30-80 ms (depends on page's size?) 423 | // - Firefox: auto-triggers after 10 ms 424 | // - Opera 18: auto-triggers after 10 ms 425 | // 426 | // - Chrome: triggers when cursor moved 427 | // - Opera 12.16: triggers when cursor moved 428 | // 429 | // To remedy to this, we do nothing if the last display occurred less 430 | // than 100 ms ago. 431 | 432 | return 433 | } 434 | 435 | var linkElement = getParentLinkElement(event.target) 436 | 437 | if (!linkElement) { 438 | return 439 | } 440 | 441 | if (linkElement == getParentLinkElement(event.relatedTarget)) { 442 | // Happens when mouseout-ing and mouseover-ing child elements of the same link element 443 | return 444 | } 445 | 446 | if (!isPreloadable(linkElement)) { 447 | return 448 | } 449 | 450 | linkElement.addEventListener('mouseout', mouseoutListener) 451 | 452 | if (!$isWaitingForCompletion) { 453 | $urlToPreload = linkElement.href 454 | $preloadTimer = setTimeout(preload, $delayBeforePreload) 455 | } 456 | } 457 | 458 | function touchstartListener(event) { 459 | $lastTouchTimestamp = +new Date 460 | 461 | var linkElement = getParentLinkElement(event.target) 462 | 463 | if (!linkElement || !isPreloadable(linkElement)) { 464 | return 465 | } 466 | 467 | if ($touchEndedWithoutClickTimer) { 468 | clearTimeout($touchEndedWithoutClickTimer) 469 | $touchEndedWithoutClickTimer = false 470 | } 471 | 472 | linkElement.addEventListener('touchend', touchendAndTouchcancelListener) 473 | linkElement.addEventListener('touchcancel', touchendAndTouchcancelListener) 474 | 475 | preload(linkElement.href) 476 | } 477 | 478 | function clickListenerPrelude() { 479 | // Makes clickListener be fired after everyone else, so that we can respect 480 | // event.preventDefault. 481 | document.addEventListener('click', clickListener) 482 | } 483 | 484 | function clickListener(event) { 485 | document.removeEventListener('click', clickListener) 486 | 487 | if ($touchEndedWithoutClickTimer) { 488 | clearTimeout($touchEndedWithoutClickTimer) 489 | $touchEndedWithoutClickTimer = false 490 | } 491 | 492 | if (event.defaultPrevented) { 493 | return 494 | } 495 | 496 | var linkElement = getParentLinkElement(event.target) 497 | 498 | if (!linkElement || !isPreloadable(linkElement)) { 499 | return 500 | } 501 | 502 | // Check if it's opening in a new tab 503 | if (event.button != 0 // Chrome < 55 fires a click event when the middle mouse button is pressed 504 | || event.metaKey 505 | || event.ctrlKey) { 506 | return 507 | } 508 | event.preventDefault() 509 | display(linkElement.href) 510 | } 511 | 512 | function mouseoutListener(event) { 513 | if (getParentLinkElement(event.target) == getParentLinkElement(event.relatedTarget)) { 514 | // Happens when mouseout-ing and mouseover-ing child elements of the same link element, 515 | // we don't want to stop preloading then. 516 | return 517 | } 518 | 519 | if ($preloadTimer) { 520 | clearTimeout($preloadTimer) 521 | $preloadTimer = false 522 | return 523 | } 524 | 525 | if (!$isPreloading || $isWaitingForCompletion) { 526 | return 527 | } 528 | 529 | $xhr.abort() 530 | setPreloadingAsHalted() 531 | } 532 | 533 | function touchendAndTouchcancelListener(event) { 534 | if (!$isPreloading || $isWaitingForCompletion) { 535 | return 536 | } 537 | 538 | $touchEndedWithoutClickTimer = setTimeout(handleTouchendWithoutClick, 500) 539 | } 540 | 541 | function readystatechangeListener() { 542 | if ($xhr.readyState == 2) { // headers received 543 | var contentType = $xhr.getResponseHeader('Content-Type') 544 | if (!contentType || !/^text\/html/i.test(contentType)) { 545 | $isContentTypeNotHTML = true 546 | } 547 | } 548 | 549 | if ($xhr.readyState < 4) { 550 | return 551 | } 552 | 553 | if ($xhr.status == 0) { 554 | // Request error/timeout/abort 555 | $gotANetworkError = true 556 | if ($isWaitingForCompletion) { 557 | triggerPageEvent('exit', $url, 'network error') 558 | location.href = $url 559 | } 560 | return 561 | } 562 | 563 | if ($isContentTypeNotHTML) { 564 | if ($isWaitingForCompletion) { 565 | triggerPageEvent('exit', $url, 'non-html content-type') 566 | location.href = $url 567 | } 568 | return 569 | } 570 | 571 | var doc = document.implementation.createHTMLDocument('') 572 | doc.documentElement.innerHTML = removeNoscriptTags($xhr.responseText) 573 | $title = doc.title 574 | $body = doc.body 575 | 576 | var alteredOnReceive = triggerPageEvent('receive', $url, $body, $title) 577 | if (alteredOnReceive) { 578 | if ('body' in alteredOnReceive) { 579 | $body = alteredOnReceive.body 580 | } 581 | if ('title' in alteredOnReceive) { 582 | $title = alteredOnReceive.title 583 | } 584 | } 585 | 586 | var urlWithoutHash = removeHash($url) 587 | $history[urlWithoutHash] = { 588 | body: $body, 589 | title: $title, 590 | scrollPosition: urlWithoutHash in $history ? $history[urlWithoutHash].scrollPosition : 0 591 | } 592 | 593 | var trackedElements = doc.querySelectorAll('[data-instant-track]') 594 | , element 595 | , elementData 596 | 597 | if (trackedElements.length != $trackedElementsData.length) { 598 | $areTrackedElementsDifferent = true 599 | } 600 | else { 601 | for (var i = 0; i < trackedElements.length; i++) { 602 | element = trackedElements[i] 603 | elementData = element.getAttribute('href') || element.getAttribute('src') || element.textContent 604 | if ($trackedElementsData.indexOf(elementData) == -1) { 605 | $areTrackedElementsDifferent = true 606 | } 607 | } 608 | } 609 | 610 | if ($isWaitingForCompletion) { 611 | $isWaitingForCompletion = false 612 | display($url) 613 | } 614 | } 615 | 616 | function popstateListener() { 617 | var loc = removeHash(location.href) 618 | if (loc == $currentLocationWithoutHash) { 619 | return 620 | } 621 | 622 | if ($isWaitingForCompletion) { 623 | setPreloadingAsHalted() 624 | $xhr.abort() 625 | } 626 | 627 | if (!(loc in $history)) { 628 | triggerPageEvent('exit', location.href, 'not in history') 629 | if (loc == location.href) { // no location.hash 630 | location.href = location.href 631 | // Reloads the page while using cache for scripts, styles and images, 632 | // unlike `location.reload()` 633 | } 634 | else { 635 | // When there's a hash, `location.href = location.href` won't reload 636 | // the page (but will trigger a popstate event, thus causing an infinite 637 | // loop), so we need to call `location.reload()` 638 | location.reload() 639 | } 640 | return 641 | } 642 | 643 | $history[$currentLocationWithoutHash].scrollPosition = pageYOffset 644 | clearCurrentPageTimeouts() 645 | addOrRemoveWindowEventListeners('remove') 646 | $currentLocationWithoutHash = loc 647 | changePage($history[loc].title, $history[loc].body, false, $history[loc].scrollPosition) 648 | addOrRemoveWindowEventListeners('add') 649 | } 650 | 651 | 652 | ////////// MAIN FUNCTIONS ////////// 653 | 654 | 655 | function preload(url) { 656 | if ($preloadTimer) { 657 | clearTimeout($preloadTimer) 658 | $preloadTimer = false 659 | } 660 | 661 | if (!url) { 662 | url = $urlToPreload 663 | } 664 | 665 | if ($isPreloading && (url == $url || $isWaitingForCompletion)) { 666 | return 667 | } 668 | $isPreloading = true 669 | $isWaitingForCompletion = false 670 | 671 | $url = url 672 | $body = false 673 | $isContentTypeNotHTML = false 674 | $gotANetworkError = false 675 | $areTrackedElementsDifferent = false 676 | triggerPageEvent('preload') 677 | $xhr.open('GET', url) 678 | $xhr.timeout = 90000 // Must be set after `open()` with IE 679 | $xhr.send() 680 | } 681 | 682 | function display(url) { 683 | $lastDisplayTimestamp = +new Date 684 | if ($preloadTimer || !$isPreloading) { 685 | // $preloadTimer: 686 | // Happens when there's a delay before preloading and that delay 687 | // hasn't expired (preloading didn't kick in). 688 | // 689 | // !$isPreloading: 690 | // A link has been clicked, and preloading hasn't been initiated. 691 | // It happens with touch devices when a user taps *near* the link, 692 | // causing `touchstart` not to be fired. Safari/Chrome will trigger 693 | // `mouseover`, `mousedown`, `click` (and others), but when that happens 694 | // we do nothing in `mouseover` as it may cause `click` not to fire (see 695 | // comment in `mouseoverListener`). 696 | // 697 | // It also happens when a user uses his keyboard to navigate (with Tab 698 | // and Return), and possibly in other non-mainstream ways to navigate 699 | // a website. 700 | 701 | if ($preloadTimer && $url && $url != url) { 702 | // Happens when the user clicks on a link before preloading 703 | // kicks in while another link is already preloading. 704 | 705 | triggerPageEvent('exit', url, 'click occured while preloading planned') 706 | location.href = url 707 | return 708 | } 709 | 710 | preload(url) 711 | triggerPageEvent('wait') 712 | $isWaitingForCompletion = true // Must be set *after* calling `preload` 713 | return 714 | } 715 | if ($isWaitingForCompletion) { 716 | // The user clicked on a link while a page to display was preloading. 717 | // Either on the same link or on another link. If it's the same link 718 | // something might have gone wrong (or he could have double clicked, we 719 | // don't handle that case), so we send him to the page without pjax. 720 | // If it's another link, it hasn't been preloaded, so we redirect the 721 | // user to it. 722 | triggerPageEvent('exit', url, 'clicked on a link while waiting for another page to display') 723 | location.href = url 724 | return 725 | } 726 | if ($isContentTypeNotHTML) { 727 | triggerPageEvent('exit', $url, 'non-html content-type') 728 | location.href = $url 729 | return 730 | } 731 | if ($gotANetworkError) { 732 | triggerPageEvent('exit', $url, 'network error') 733 | location.href = $url 734 | return 735 | } 736 | if ($areTrackedElementsDifferent) { 737 | triggerPageEvent('exit', $url, 'different assets') 738 | location.href = $url 739 | return 740 | } 741 | if (!$body) { 742 | triggerPageEvent('wait') 743 | $isWaitingForCompletion = true 744 | return 745 | } 746 | $history[$currentLocationWithoutHash].scrollPosition = pageYOffset 747 | setPreloadingAsHalted() 748 | changePage($title, $body, $url) 749 | } 750 | 751 | 752 | ////////// PUBLIC VARIABLE AND FUNCTIONS ////////// 753 | 754 | 755 | var supported = false 756 | if ('pushState' in history 757 | && location.protocol != "file:") { 758 | supported = true 759 | 760 | var indexOfAndroid = $userAgent.indexOf('Android ') 761 | if (indexOfAndroid > -1) { 762 | // The stock browser in Android 4.0.3 through 4.3.1 supports pushState, 763 | // though it doesn't update the address bar. 764 | // 765 | // More problematic is that it has a bug on `popstate` when coming back 766 | // from a page not displayed through InstantClick: `location.href` is 767 | // undefined and `location.reload()` doesn't work. 768 | // 769 | // Android < 4.4 is therefore blacklisted, unless it's a browser known 770 | // not to have that latter bug. 771 | 772 | var androidVersion = parseFloat($userAgent.substr(indexOfAndroid + 'Android '.length)) 773 | if (androidVersion < 4.4) { 774 | supported = false 775 | if (androidVersion >= 4) { 776 | var whitelistedBrowsersUserAgentsOnAndroid4 = [ 777 | / Chrome\//, // Chrome, Opera, Puffin, QQ, Yandex 778 | / UCBrowser\//, 779 | / Firefox\//, 780 | / Windows Phone /, // WP 8.1+ pretends to be Android 781 | ] 782 | for (var i = 0; i < whitelistedBrowsersUserAgentsOnAndroid4.length; i++) { 783 | if (whitelistedBrowsersUserAgentsOnAndroid4[i].test($userAgent)) { 784 | supported = true 785 | break 786 | } 787 | } 788 | } 789 | } 790 | } 791 | } 792 | 793 | function init(preloadingMode) { 794 | if (!supported) { 795 | triggerPageEvent('change', true) 796 | return 797 | } 798 | 799 | if ($hasBeenInitialized) { 800 | return 801 | } 802 | $hasBeenInitialized = true 803 | 804 | if (preloadingMode == 'mousedown') { 805 | $preloadOnMousedown = true 806 | } 807 | else if (typeof preloadingMode == 'number') { 808 | $delayBeforePreload = preloadingMode 809 | } 810 | 811 | $currentLocationWithoutHash = removeHash(location.href) 812 | $timers[$currentLocationWithoutHash] = {} 813 | $history[$currentLocationWithoutHash] = { 814 | body: document.body, 815 | title: document.title, 816 | scrollPosition: pageYOffset 817 | } 818 | 819 | if (document.readyState == 'loading') { 820 | document.addEventListener('DOMContentLoaded', addTrackedElements) 821 | } 822 | else { 823 | addTrackedElements() 824 | } 825 | 826 | $xhr = new XMLHttpRequest() 827 | $xhr.addEventListener('readystatechange', readystatechangeListener) 828 | 829 | document.addEventListener('touchstart', touchstartListener, true) 830 | if ($preloadOnMousedown) { 831 | document.addEventListener('mousedown', mousedownListener, true) 832 | } 833 | else { 834 | document.addEventListener('mouseover', mouseoverListener, true) 835 | } 836 | document.addEventListener('click', clickListenerPrelude, true) 837 | 838 | addEventListener('popstate', popstateListener) 839 | } 840 | 841 | function on(eventType, callback) { 842 | $eventsCallbacks[eventType].push(callback) 843 | 844 | if (eventType == 'change') { 845 | callback(!$lastDisplayTimestamp) 846 | } 847 | } 848 | 849 | function setTimeout() { 850 | return addTimer(arguments, false) 851 | } 852 | 853 | function setInterval() { 854 | return addTimer(arguments, true) 855 | } 856 | 857 | function clearTimeout(id) { 858 | id = -id 859 | for (var loc in $timers) { 860 | if (id in $timers[loc]) { 861 | window.clearTimeout($timers[loc][id].realId) 862 | delete $timers[loc][id] 863 | } 864 | } 865 | } 866 | 867 | function xhr(xhr) { 868 | $currentPageXhrs.push(xhr) 869 | } 870 | 871 | function addPageEvent() { 872 | if (!($currentLocationWithoutHash in $windowEventListeners)) { 873 | $windowEventListeners[$currentLocationWithoutHash] = [] 874 | } 875 | $windowEventListeners[$currentLocationWithoutHash].push(arguments) 876 | addEventListener.apply(window, arguments) 877 | } 878 | 879 | function removePageEvent() { 880 | if (!($currentLocationWithoutHash in $windowEventListeners)) { 881 | return 882 | } 883 | firstLoop: 884 | for (var i = 0; i < $windowEventListeners[$currentLocationWithoutHash].length; i++) { 885 | if (arguments.length != $windowEventListeners[$currentLocationWithoutHash][i].length) { 886 | continue 887 | } 888 | for (var j = 0; j < $windowEventListeners[$currentLocationWithoutHash][i].length; j++) { 889 | if (arguments[j] != $windowEventListeners[$currentLocationWithoutHash][i][j]) { 890 | continue firstLoop 891 | } 892 | } 893 | $windowEventListeners[$currentLocationWithoutHash].splice(i, 1) 894 | } 895 | } 896 | 897 | function addEvent(selector, type, listener) { 898 | if (!(type in $delegatedEvents)) { 899 | $delegatedEvents[type] = {} 900 | 901 | document.addEventListener(type, function(event) { 902 | var element = event.target 903 | event.originalStopPropagation = event.stopPropagation 904 | event.stopPropagation = function() { 905 | this.isPropagationStopped = true 906 | this.originalStopPropagation() 907 | } 908 | while (element && element.nodeType == 1) { 909 | for (var selector in $delegatedEvents[type]) { 910 | if (element.matches(selector)) { 911 | for (var i = 0; i < $delegatedEvents[type][selector].length; i++) { 912 | $delegatedEvents[type][selector][i].call(element, event) 913 | } 914 | if (event.isPropagationStopped) { 915 | return 916 | } 917 | break 918 | } 919 | } 920 | element = element.parentNode 921 | } 922 | }, false) // Third parameter isn't optional in Firefox < 6 923 | 924 | if (type == 'click' && /iP(?:hone|ad|od)/.test($userAgent)) { 925 | // Force Mobile Safari to trigger the click event on document by adding a pointer cursor to body 926 | 927 | var styleElement = document.createElement('style') 928 | styleElement.setAttribute('instantclick-mobile-safari-cursor', '') // So that this style element doesn't surprise developers in the browser DOM inspector. 929 | styleElement.textContent = 'body { cursor: pointer !important; }' 930 | document.head.appendChild(styleElement) 931 | } 932 | } 933 | 934 | if (!(selector in $delegatedEvents[type])) { 935 | $delegatedEvents[type][selector] = [] 936 | } 937 | 938 | // Run removeEvent beforehand so that it can't be added twice 939 | removeEvent(selector, type, listener) 940 | 941 | $delegatedEvents[type][selector].push(listener) 942 | } 943 | 944 | function removeEvent(selector, type, listener) { 945 | var index = $delegatedEvents[type][selector].indexOf(listener) 946 | if (index > -1) { 947 | $delegatedEvents[type][selector].splice(index, 1) 948 | } 949 | } 950 | 951 | 952 | //////////////////// 953 | 954 | 955 | return { 956 | supported: supported, 957 | init: init, 958 | on: on, 959 | setTimeout: setTimeout, 960 | setInterval: setInterval, 961 | clearTimeout: clearTimeout, 962 | xhr: xhr, 963 | addPageEvent: addPageEvent, 964 | removePageEvent: removePageEvent, 965 | addEvent: addEvent, 966 | removeEvent: removeEvent 967 | } 968 | 969 | }(document, location, navigator.userAgent); 970 | -------------------------------------------------------------------------------- /src/loading-indicator.js: -------------------------------------------------------------------------------- 1 | /* InstantClick's loading indicator | (C) 2014-2017 Alexandre Dieulot | http://instantclick.io/license */ 2 | 3 | ;(function() { 4 | var $element 5 | , $timer 6 | 7 | function init() { 8 | $element = document.createElement('div') 9 | $element.id = 'instantclick' 10 | 11 | var vendors = { 12 | Webkit: true, 13 | Moz: true 14 | } 15 | , vendorPrefix = '' 16 | 17 | if (!('transform' in $element.style)) { 18 | for (var vendor in vendors) { 19 | if (vendor + 'Transform' in $element.style) { 20 | vendorPrefix = '-' + vendor.toLowerCase() + '-' 21 | } 22 | } 23 | } 24 | 25 | var styleElement = document.createElement('style') 26 | styleElement.setAttribute('instantclick-loading-indicator', '') // So that this style element doesn't surprise developers in the browser DOM inspector. 27 | styleElement.textContent = '#instantclick {pointer-events:none; z-index:2147483647; position:fixed; top:0; left:0; width:100%; height:3px; border-radius:2px; color:hsl(192,100%,50%); background:currentColor; box-shadow: 0 -1px 8px; opacity: 0;}' + 28 | '#instantclick.visible {opacity:1; ' + vendorPrefix + 'animation:instantclick .6s linear infinite;}' + 29 | '@' + vendorPrefix + 'keyframes instantclick {0%,5% {' + vendorPrefix + 'transform:translateX(-100%);} 45%,55% {' + vendorPrefix + 'transform:translateX(0%);} 95%,100% {' + vendorPrefix + 'transform:translateX(100%);}}' 30 | document.head.appendChild(styleElement) 31 | } 32 | 33 | function changeListener(isInitialPage) { 34 | if (!instantclick.supported) { 35 | return 36 | } 37 | 38 | if (isInitialPage) { 39 | init() 40 | } 41 | 42 | document.body.appendChild($element) 43 | 44 | if (!isInitialPage) { 45 | hide() 46 | } 47 | } 48 | 49 | function restoreListener() { 50 | document.body.appendChild($element) 51 | 52 | hide() 53 | } 54 | 55 | function waitListener() { 56 | $timer = instantclick.setTimeout(show, 800) 57 | } 58 | 59 | function show() { 60 | $element.className = 'visible' 61 | } 62 | 63 | function hide() { 64 | instantclick.clearTimeout($timer) 65 | 66 | $element.className = '' 67 | // Doesn't work (has no visible effect) in Safari on `exit`. 68 | // 69 | // My guess is that Safari queues styling change for the next frame and 70 | // drops that queue on location change. 71 | } 72 | 73 | 74 | //////////////////// 75 | 76 | 77 | instantclick.on('change', changeListener) 78 | instantclick.on('restore', restoreListener) 79 | instantclick.on('wait', waitListener) 80 | instantclick.on('exit', hide) 81 | 82 | 83 | //////////////////// 84 | 85 | 86 | instantclick.loadingIndicator = { 87 | show: show, 88 | hide: hide 89 | } 90 | })(); 91 | -------------------------------------------------------------------------------- /tests/anchors.html: -------------------------------------------------------------------------------- 1 |

Page with anchors #

2 | 3 | 4 | 5 | 6 |
Page , anchor 7 | 8 | 9 | 11 |
12 | 13 |

this is anchor #1

14 | 15 |

This

16 |

is

17 |

a

18 |

long

19 | 20 | 21 | 22 | 23 |
Page , anchor 24 | 25 | 26 | 28 |
29 | 30 |

this is anchor #2

31 | 32 |

page

33 |

so

34 |

we

35 |

can

36 |

test

37 |

anchors

38 | 39 | 40 | 41 | 42 |
Page , anchor 43 | 44 | 45 | 47 |
48 | 49 |

this is anchor #3

50 | 51 |

more

52 |

text

53 |

just

54 |

for

55 |

your

56 |

convenience

57 | 58 | Index page 59 | -------------------------------------------------------------------------------- /tests/index.php: -------------------------------------------------------------------------------- 1 | 'index', 4 | 'Page with anchors #1' => 'anchor1', 5 | 'Page with anchors #2' => 'anchor2', 6 | 'Page without title' => 'no-title', 7 | 'Minimal markup' => 'minimal', 8 | 'Entities in the ‹title›' => 'entities', 9 | 'Noscript' => 'noscript', 10 | 'Alter on receive' => 'alter-receive', 11 | 'Childs and blacklist' => 'child-n-blacklist', 12 | 'Non-HTML file' => 'non-html', 13 | 'Touch events' => 'touch', 14 | 'Event delegation' => 'event-delegation', 15 | ]; 16 | 17 | if (isset($_GET['page']) && in_array($_GET['page'], $pages)) { 18 | $page = $_GET['page']; 19 | } 20 | else { 21 | $page = 'index'; 22 | } 23 | 24 | $delays = [200, 500, 1000, 5000]; 25 | 26 | if (isset($_GET['on'])) { 27 | if (in_array($_GET['on'], ['default', 'mousedown'])) { 28 | $preload_on = $_GET['on']; 29 | } 30 | else { 31 | $preload_on = (int)$_GET['on']; 32 | } 33 | } 34 | else { 35 | $preload_on = 'default'; 36 | } 37 | 38 | $nocache = '&nocache=' . microtime(true) * 10000; 39 | $append = '&on=' . $preload_on . $nocache; 40 | 41 | if (isset($_GET['wait'])) { 42 | usleep((int)$_GET['wait'] * 1000); 43 | } 44 | ?> 45 | 46 | 47 | 48 | 49 | <?php if ($page == 'entities'): ?> 50 | Entities in the ‹title› 51 | <?php else: ?> 52 | <?= date('H : i : s') ?> . <?php printf("%03d", microtime() * 1000) ?> 53 | <?php endif ?> 54 | 55 | 56 | 57 | 58 | 59 | 61 | Hiya. 62 | 64 | 65 |
66 | ↻ Default 67 | ↻ 0 ms 68 | ↻ 1000 ms 69 | ↻ Mousedown 70 |
71 | 72 |
73 | 74 | 75 | 76 | 77 | 78 | 79 | $row): ?> 80 | 81 |
PageDelays (in milliseconds)
82 | 83 | 84 | 86 |
87 | 88 |
89 | 90 | 91 | 92 |
93 | 94 | 95 | 96 | 97 | 98 | 170 | -------------------------------------------------------------------------------- /tests/instantclick.js.php: -------------------------------------------------------------------------------- 1 | 8 | Text file. 9 | -------------------------------------------------------------------------------- /tests/pages/alter-receive.html: -------------------------------------------------------------------------------- 1 |

Alter on receive

2 | 3 |

Tests if altering the body and title on the receive event works. 4 | 5 |

Test it (no alteration) 6 | 7 |

This will be altered. Currently this is not. 8 | 9 |

The title should also be altered. 10 | -------------------------------------------------------------------------------- /tests/pages/anchor1.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /tests/pages/anchor2.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /tests/pages/child-n-blacklist.html: -------------------------------------------------------------------------------- 1 |

Childs and blacklist

2 | 3 |

A link with childs. 4 | 5 |

A link with multiple childs cohabiting. #65 6 | 7 |

A link in a blacklisted element. 8 |
A link in a whitelisted element. 9 |
A link in a blacklisted element in a whitelisted element. Should blacklist. 10 |
A link in a whitelisted element in a blacklisted element. Should whitelist. 11 | -------------------------------------------------------------------------------- /tests/pages/entities.html: -------------------------------------------------------------------------------- 1 |

A page with entities in the <title>

2 | 3 |

And a free return line to top it off. 4 | -------------------------------------------------------------------------------- /tests/pages/event-delegation.html: -------------------------------------------------------------------------------- 1 |

Event delegation

2 | 3 | 15 | 16 |
17 |
18 | Hey 19 |
20 |
21 | 22 | 23 |
24 |
25 | Hey 26 |
27 |
28 | 29 | Link with preventDefault 30 | 31 | 60 | -------------------------------------------------------------------------------- /tests/pages/index.html: -------------------------------------------------------------------------------- 1 |

Index page

2 | 3 |

The different tests are listed above. 4 | 5 |

Here is a list of browsers I test with before release: 6 | 7 |

17 | 18 |

The callbacks attached to InstantClick’s change event should run on every browser (doesn’t happen if the code throws an error), so I test that with Internet Explorer 6. 19 | -------------------------------------------------------------------------------- /tests/pages/no-title.html: -------------------------------------------------------------------------------- 1 |

A page without a title tag

2 | -------------------------------------------------------------------------------- /tests/pages/non-html.html: -------------------------------------------------------------------------------- 1 |

Non-HTML file

2 | 3 |

A link to a non-HTML file. Should redirect. 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/pages/noscript.html: -------------------------------------------------------------------------------- 1 |

Noscript test

2 | 3 |

You should see another paragraph below this one. 4 | 5 |

This should not display. Well, unless you disabled JavaScript. 6 | 7 |

If you don’t see a red paragraph above this one, you’re good. 8 | 9 |

This should not display. Well, unless you disabled JavaScript. 10 | -------------------------------------------------------------------------------- /tests/pages/touch.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ms 5 | 6 | 7 | 8 | 21 | -------------------------------------------------------------------------------- /tests/style.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: midnightblue; /* because there's lots of links, default blue is too aggressive */ 3 | } 4 | 5 | a:visited { 6 | color: purple; 7 | } 8 | 9 | 10 | body { 11 | font: 18px/1.4 sans-serif; 12 | margin-left: 20px; 13 | } 14 | 15 | .selected { 16 | background: #ccf; 17 | } 18 | 19 | #divDebug { 20 | border: 1px solid grey; 21 | color: #333; 22 | font-size: 14px; 23 | padding: .5em; 24 | } 25 | 26 | #divDebug hr { 27 | margin: .2em 0; 28 | } 29 | 30 | #instantclick { 31 | _display: none; 32 | } 33 | #instantclick-bar { 34 | _display: none; 35 | } 36 | #instantclick { 37 | _color: red; 38 | } 39 | /* An underscore before a property name acts like a comment, remove it to enable a declaration. */ 40 | --------------------------------------------------------------------------------