UNPKG

@pageworks/pjax

Version:

Turns any website into a SPA using Fetch and link prefetching.

1,000 lines (837 loc) 36 kB
// Function Imports import parseOptions from './lib/parse-options'; import trigger from './lib/events/trigger'; import parseDOM from './lib/parse-dom'; import scrollWindow from './lib/util/scroll'; import clearActive from './lib/util/clear-active'; // Custom Packages import StateManager from '@pageworks/state-manager'; import DeviceManager from '@pageworks/device-manager'; // Type Definitions import PJAX from './global'; export default class Pjax{ public static VERSION:string = '2.3.2'; public options : PJAX.IOptions; private _cache : PJAX.ICacheObject; private _request : string; private _response : Response; private _confirmed : boolean; private _cachedSwitch : PJAX.ICachedSwitchOptions; private _scrollTo : PJAX.IScrollPosition; private _isPushstate : boolean; private _dom : HTMLElement; private _scriptsToAppend : Array<HTMLScriptElement>; private _requestId : number; private _transitionFinished : boolean; constructor(options?:PJAX.IOptions){ this._dom = document.documentElement; // If IE 11 is detected abort pjax if(DeviceManager.isIE){ console.log('%c[Pjax] '+`%cIE 11 detected - Pjax aborted`,'color:#f3ff35','color:#eee'); this._dom.classList.remove('dom-is-loading'); this._dom.classList.add('dom-is-loaded'); return; } this._cache = null; this.options = parseOptions(options); this._request = null; this._response = null; this._confirmed = false; this._cachedSwitch = null; this._scrollTo = {x:0, y:0}; this._isPushstate = true; this._scriptsToAppend = []; this._requestId = 0; this._transitionFinished = false; this.init(); } init(){ if(this.options.debug){ console.group(); console.log('%c[Pjax] '+`%cinitializing Pjax version ${ Pjax.VERSION }`, 'color:#f3ff35','color:#eee'); console.log('%c[Pjax] '+`%cview Pjax documentation at https://github.com/Pageworks/pjax`, 'color:#f3ff35','color:#eee'); console.log('%c[Pjax] '+`%cloaded with the following options: `, 'color:#f3ff35','color:#eee'); console.log(this.options); console.groupEnd(); } // Handle status classes for initial load this._dom.classList.add('dom-is-loaded'); this._dom.classList.remove('dom-is-loading'); new StateManager(this.options.debug, true); window.addEventListener('popstate', this.handlePopstate); if(this.options.customTransitions){ document.addEventListener('pjax:continue', this.handleContinue ); } document.addEventListener('pjax:load', this.handleManualLoad); // Attach listeners to initial link elements parseDOM(document.body, this); } /** * Fired when the custom `pjax:load` event fires on the `document` */ private handleManualLoad:EventListener = (e:CustomEvent)=>{ const uri = e.detail.uri; if(this.options.debug){ console.log('%c[Pjax] '+`%cmanually loading ${ uri }`,'color:#f3ff35','color:#eee'); } this.doRequest(uri); } /** * Fired when the `popstate` event is fired on the `window`. */ private handlePopstate:EventListener = (e:PopStateEvent)=>{ // Check if the state object is available if(e.state){ if(this.options.debug){ console.log('%c[Pjax] '+`%chijacking popstate event`,'color:#f3ff35','color:#eee'); } this._scrollTo = e.state.scrollPos; this.loadUrl(e.state.uri, 'popstate'); } } /** * * @param href - URI from the popstates state object * @param loadType - the load type that should be perfomred (eg: popstate) */ private loadUrl(href:string, loadType:string):void{ if(this._confirmed){ return; } // Abort any current request this.abortRequest(); this._cache = null; // Handle the page load request this.handleLoad(href, loadType); } /** * Called when the current request needs to be aborted. */ private abortRequest(): void{ // Reset the request this._request = null; // Reset the response this._response = null; } /** * Called when Pjax needs to finish the page transition. * This method is responsible for cleaning up all the status trackers. */ private finalize(): void{ if(this.options.debug){ console.log('%c[Pjax] '+`%cpage transition completed`,'color:#f3ff35','color:#eee'); } // Update the windows scroll position scrollWindow(this._scrollTo); // Handle the pushState if(this.options.history){ if(this._isPushstate){ StateManager.doPush(this._response.url, document.title); }else{ StateManager.doReplace(this._response.url, document.title); } } // Trigger the complete event trigger(document, ['pjax:complete']); if(!this._scriptsToAppend.length){ if(this.options.debug){ console.log('%c[Pjax] '+`%cNo new scripts to load`,'color:#f3ff35','color:#eee'); trigger(document, ['pjax:scriptContentLoaded']); } } // Handle status classes this._dom.classList.add('dom-is-loaded'); this._dom.classList.remove('dom-is-loading'); // Reset status trackers this._cache = null; this._request = null; this._response = null; this._cachedSwitch = null; this._isPushstate = true; this._scrollTo = {x:0,y:0}; this._confirmed = false; this._transitionFinished = false; } /** * Loops through the `SwitchObject` array and swapps the `innerHTML` from the containers. * @param switchQueue -`SwitchObject` array */ private handleSwitches(switchQueue:Array<PJAX.ISwitchObject>): void{ // Loop through all the queued switch objects for(let i = 0; i < switchQueue.length; i++){ // Swap out the innerHTML switchQueue[i].current.innerHTML = switchQueue[i].new.innerHTML; // Parse the new container and handle event listeners parseDOM(switchQueue[i].current, this); } // Finalize the page transition this.finalize(); } /** * Called when the `pjax:continue` event is fired. * Only listening if the `customTransition` is enabled in the options object. */ private handleContinue:EventListener = (e:Event)=>{ this._transitionFinished = true; if (this._cachedSwitch !== null) { if (this.options.titleSwitch) { document.title = this._cachedSwitch.title; } this.handleSwitches(this._cachedSwitch.queue); } } /** * Builds an array of `SwitchObjects` that need to be swapped based on the `options.selectors` array. * @param selectors - an array of container selectors that Pjax needs to swap * @param tempDocument - the temporary `HTMLDocument` generated from the fetch response */ private switchSelectors(selectors:string[], tempDocument:HTMLDocument): void{ if(tempDocument === null){ if(this.options.debug){ console.log('%c[Pjax] '+`%ctemporary document was null, telling the browser to load ${ (this._cache !== null) ? this._cache.url : this._response.url }`,'color:#f3ff35','color:#eee'); } if(this._cache !== null){ this.lastChance(this._cache.url); }else{ this.lastChance(this._response.url); } return; } // If `importScripts` is false check if Pjax needs to native load the page if(!this.options.importScripts){ // Get the script elements from the temp document const newScripts = Array.from(tempDocument.querySelectorAll('script')); if(newScripts.length){ // Get the script elements from the current document const currentScripts = Array.from(document.querySelectorAll('script')); newScripts.forEach((newScript)=>{ // Assume the script is new let isNewScript = true; currentScripts.forEach((currentScript)=>{ // Check if the new script is already on the document if(newScript.src === currentScript.src){ isNewScript = false; } }); // If the new script is not already on the document load the new page using the native browser functionality if(isNewScript){ // Abort switch if one of the new containers contains a script element if(this.options.debug){ console.log('%c[Pjax] '+`%cthe new page contains scripts`,'color:#f3ff35','color:#eee'); } this.lastChance(this._response.url); } }); } } // If `importsCSS` is false check if Pjax needs to native load the page if(!this.options.importCSS){ // Get the script elements from the temp document const newStylesheets = Array.from(tempDocument.querySelectorAll('link[rel="stylesheet"]')); if(newStylesheets.length){ // Get the script elements from the current document const currentStylesheets = Array.from(document.querySelectorAll('link[rel="stylesheet"]')); newStylesheets.forEach((newStylesheet)=>{ // Assume the sheet is new let isNewSheet = true; currentStylesheets.forEach((currentStylesheet)=>{ // Check if the new script is already on the document if(newStylesheet.getAttribute('href') === currentStylesheet.getAttribute('href')){ isNewSheet = false; } }); // If the new script is not already on the document load the new page using the native browser functionality if(isNewSheet){ // Abort switch if one of the new containers contains a script element if(this.options.debug){ console.log('%c[Pjax] '+`%cthe new page contains new stylesheets`,'color:#f3ff35','color:#eee'); } this.lastChance(this._response.url); } }); } } // Build a queue of containers to swap const switchQueue:Array<PJAX.ISwitchObject> = []; // Loop though all the selector strings for(let i = 0; i < selectors.length; i++){ // Grab the selector containers from the temporay and current documents const newContainers = Array.from(tempDocument.querySelectorAll(selectors[i])); const currentContainers = Array.from(document.querySelectorAll(selectors[i])); if(this.options.debug){ console.log('%c[Pjax] '+`%cswapping content from ${ selectors[i] }`,'color:#f3ff35','color:#eee'); } // Check that the selector contains exist on both documents if(newContainers.length !== currentContainers.length){ if(this.options.debug){ console.log('%c[Pjax] '+`%cthe dom doesn't look the same`,'color:#f3ff35','color:#eee'); } // If a document is missing a container natively load the page this.lastChance(this._response.url); return; } // Loop though all the new containers for(let k = 0; k < newContainers.length; k++){ // Get the current container object const newContainer = newContainers[k]; const currentContainer = currentContainers[k]; // Build the switch object const switchObject:PJAX.ISwitchObject = { new: newContainer, current: currentContainer }; // Queue the switch switchQueue.push(switchObject); } } // Check that there are switch objects in the queue if(switchQueue.length === 0){ if(this.options.debug){ console.log('%c[Pjax] '+`%ccouldn't find anything to switch`,'color:#f3ff35','color:#eee'); } this.lastChance(this._response.url); return; } if(this.options.importScripts){ this.handleScripts(tempDocument); } if(this.options.importCSS){ if (!this.options.requireCssBeforeComplete) { this.handleCSS(tempDocument); } else { this.handleSynchronousCss(tempDocument) .then(()=>{ // Check if Pjax needs to wait for the continue event if(!this.options.customTransitions){ if(this.options.titleSwitch){ document.title = tempDocument.title; } this.handleSwitches(switchQueue); }else{ this._cachedSwitch = { queue: switchQueue, title: tempDocument.title }; if (this._transitionFinished) { if (this.options.titleSwitch) { document.title = this._cachedSwitch.title; } this.handleSwitches(this._cachedSwitch.queue); } } }); } } if (this.options.importCSS && this.options.requireCssBeforeComplete) { return; } // Check if Pjax needs to wait for the continue event if (!this.options.customTransitions) { if(this.options.titleSwitch){ document.title = tempDocument.title; } this.handleSwitches(switchQueue); }else{ this._cachedSwitch = { queue: switchQueue, title: tempDocument.title }; if (this._transitionFinished) { if (this.options.titleSwitch) { document.title = this._cachedSwitch.title; } this.handleSwitches(this._cachedSwitch.queue); } } } /** * Called when something went wrong with Pjax. * This fallback will force the browser to natively load the page. * @param uri - URI of the page that should be natively loaded */ private lastChance(uri:string): void{ if(this.options.debug){ console.log('%c[Pjax] '+`%csomething caused Pjax to break, native loading ${ uri }`,'color:#f3ff35','color:#eee'); } window.location.href = uri; } /** * Check if the cached content has a successful response. * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status */ private statusCheck(): boolean{ for(let status = 200; status <= 206; status++){ if(this._cache.status === status){ return true; } } return false; } /** * Called when Pjax needs to load the temporary `HTMLDocument` that's currently cached. */ private loadCachedContent(): void{ // Verify that the server responded with a 200 level response status if (!this.statusCheck()) { this.lastChance(this._cache.url); return; } // Clear the active element clearActive(); StateManager.doReplace(window.location.href, document.title); // Build the selector swapping queue this.switchSelectors(this.options.selectors, this._cache.document); } /** * Builds a temporary `HTMLDocument` using the `responseText` provided by the `XMLHttpRequest` response. * @param responseText - `responseText` from the `XMLHttpRequest` response * @returns `HTMLDocument` or `null` */ private parseContent(responseText:string): HTMLDocument{ const tempDocument:HTMLDocument = document.implementation.createHTMLDocument('pjax-temp-document'); const contentType = this._response.headers.get('Content-Type'); if (contentType === null) { return null; } const htmlRegex = /text\/html/gi; const matches = contentType.match(htmlRegex); // Check that the regex match was successful if(matches !== null){ tempDocument.documentElement.innerHTML = responseText; return tempDocument; } return null; } /** * Caches the response from the server in a temporary `HTMLDocument`. * @param responseText - `responseText` from the `XMLHttpRequest` response * @param responseStatus - `status` from the `XMLHttpRequest` response * @param uri - the URI that was used for the request */ private cacheContent(responseText:string, responseStatus:number, uri:string): void{ // Create a temp HTML document const tempDocument = this.parseContent(responseText); // Create the cache object this._cache = { status: responseStatus, document: tempDocument, url: uri } // Check that the temp document is valid if(tempDocument instanceof HTMLDocument){ if(this.options.debug){ console.log('%c[Pjax] '+`%ccaching content`,'color:#f3ff35','color:#eee'); } }else{ if(this.options.debug){ console.log('%c[Pjax] '+`%cresponse wan't an HTML document`,'color:#f3ff35','color:#eee'); } // Trigger error if the response wasn't an HTML Document trigger(document, ['pjax:error']); } } /** * Handles building the new `HTMLDocument` * @param responseText - `responseText` */ private loadContent(responseText:string): void{ // Create a temp HTML document const tempDocument = this.parseContent(responseText); if(tempDocument instanceof HTMLDocument){ // Clear the active element clearActive(); StateManager.doReplace(window.location.href, document.title); // Swap the current page with the new page this.switchSelectors(this.options.selectors, tempDocument); }else{ if(this.options.debug){ console.log('%c[Pjax] '+`%cresponse wasn't an HTML document`,'color:#f3ff35','color:#eee'); } // Trigger the error event trigger(document, ['pjax:error']); // Have the browser load the page natively this.lastChance(this._response.url); return; } } /** * Append any `<script>` elements onto the current `HTMLDocument` * @param newDocument - `HTMLDocument` */ private handleScripts(newDocument:HTMLDocument):void{ if(newDocument instanceof HTMLDocument){ const newScripts = Array.from(newDocument.querySelectorAll('script')); const currentScripts = Array.from(document.querySelectorAll('script')); newScripts.forEach((newScript)=>{ let appendScript = true; let newScriptFilename = 'inline-script'; if (newScript.getAttribute('src') !== null) { newScriptFilename = newScript.getAttribute('src').match(/[^/]+$/g)[0]; } currentScripts.forEach((currentScript)=>{ let currentScriptFilename = 'inline-script'; if (currentScript.getAttribute('src') !== null) { currentScriptFilename = currentScript.getAttribute('src').match(/[^/]+$/g)[0]; } if(newScriptFilename === currentScriptFilename && newScriptFilename !== 'inline-script'){ appendScript = false; } }); if(appendScript){ this._scriptsToAppend.push(newScript); } }); // Append the new scripts to the body if(this._scriptsToAppend.length){ let scriptLoadCount = 0; this._scriptsToAppend.forEach((script)=>{ // Append inline script elements if(script.src === ''){ const newScript = document.createElement('script'); newScript.dataset.src = this._response.url; newScript.innerHTML = script.innerHTML; this.options.scriptImportLocation.appendChild(newScript); scriptLoadCount++; this.checkForScriptLoadComplete(scriptLoadCount); } else { fetch(script.src, { method: 'GET', credentials: 'include', headers: new Headers({ 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/javascript' }) }) .then(request => request.text()) .then(response => { const newScript = document.createElement('script'); newScript.setAttribute('src', script.src); newScript.innerHTML = response; this.options.scriptImportLocation.appendChild(newScript); scriptLoadCount++; this.checkForScriptLoadComplete(scriptLoadCount); }) .catch(error => { console.error('Failed to fetch script', script.src, error); }); } }); } } } /** * Handles the custom `DOMContentLoaded` style event. * If a `script` is provided splice it from the array of scripts to append. * @param script - `HTMLScriptElement` */ private checkForScriptLoadComplete(scriptCount:number){ if (scriptCount === this._scriptsToAppend.length) { if(this.options.debug){ console.log('%c[Pjax] '+`%cAll scripts have been loaded`,'color:#f3ff35','color:#eee'); } this._scriptsToAppend = []; // Trigger the content loaded event trigger(document, ['pjax:scriptContentLoaded']); } } /** * Append any `<style>` or `<link rel="stylesheet">` elements onto the current documents head * @param newDocument - `HTMLDocument` */ private handleCSS(newDocument:HTMLDocument) : void { if (newDocument instanceof HTMLDocument) { const newStyles:Array<HTMLLinkElement> = Array.from(newDocument.querySelectorAll('link[rel="stylesheet"]')); const currentStyles:Array<HTMLElement> = Array.from(document.querySelectorAll('link[rel="stylesheet"], style[href]')); const stylesToAppend:Array<HTMLLinkElement> = []; newStyles.forEach((newStyle)=>{ let appendStyle = true; const newStyleFile = newStyle.getAttribute('href').match(/[^/]+$/g)[0]; currentStyles.forEach((currentStyle)=>{ const currentStyleFile = currentStyle.getAttribute('href').match(/[^/]+$/g)[0]; if(newStyleFile === currentStyleFile){ appendStyle = false; } }); if (appendStyle) { stylesToAppend.push(newStyle); } }); // Append the new `link` styles to the head if(stylesToAppend.length){ stylesToAppend.forEach((style)=>{ fetch(style.href, { method: 'GET', credentials: 'include', headers: new Headers({ 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/javascript' }) }) .then(request => request.text()) .then(response => { const newStyle = document.createElement('style'); newStyle.setAttribute('rel', 'stylesheet'); newStyle.setAttribute('href', style.href); newStyle.innerHTML = response; document.head.appendChild(newStyle); }) .catch(error => { console.error('Failed to fetch stylesheet', style.href, error); }); }); } } } private handleSynchronousCss(newDocument:HTMLDocument) : Promise<any> { return new Promise((resolve)=>{ if (newDocument instanceof HTMLDocument) { const newStyles:Array<HTMLLinkElement> = Array.from(newDocument.querySelectorAll('link[rel="stylesheet"]')); const currentStyles:Array<HTMLElement> = Array.from(document.querySelectorAll('link[rel="stylesheet"], style[href]')); const stylesToAppend:Array<HTMLLinkElement> = []; let fetched = 0; newStyles.forEach((newStyle)=>{ let appendStyle = true; const newStyleFile = newStyle.getAttribute('href').match(/[^/]+$/g)[0]; currentStyles.forEach((currentStyle)=>{ const currentStyleFile = currentStyle.getAttribute('href').match(/[^/]+$/g)[0]; if (newStyleFile === currentStyleFile) { appendStyle = false; } }); if (appendStyle) { stylesToAppend.push(newStyle); } }); // Append the new `link` styles to the head if (stylesToAppend.length) { stylesToAppend.forEach((style)=>{ fetch(style.href, { method: 'GET', credentials: 'include', headers: new Headers({ 'X-Requested-With': 'XMLHttpRequest' }) }) .then(request => request.text()) .then(response => { const newStyle = document.createElement('style'); newStyle.setAttribute('rel', 'stylesheet'); newStyle.setAttribute('href', style.href); newStyle.innerHTML = response; document.head.appendChild(newStyle); }) .catch(error => { console.error('Failed to fetch stylesheet', style.href, error); }) .then(()=>{ fetched++; if (fetched === stylesToAppend.length) { resolve(); } }); }); } else { resolve(); } } }); } /** * Handles the fetch `Response` based on the load type. * @param response - `Reponse` object provided by the Fetch API * @param loadType - informs the response handler what type of load is being preformed (eg: reload) * @see https://developer.mozilla.org/en-US/docs/Web/API/Response * @see https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API */ private handleResponse(response:Response): void{ // If the request is null (aborted) do nothing if (this._request === null) { return; } if (this.options.debug) { console.log('%c[Pjax] '+`%cRequest status: ${ response.status }`,'color:#f3ff35','color:#eee'); } // Check if the server response is valid if (!response.ok) { trigger(document, ['pjax:error']); return; } this._response = response; response.text().then((responseText:string)=>{ // Handle the response based on the load type provided switch(this._request){ case 'prefetch': if (this._confirmed) { this.loadContent(responseText); } else { this.cacheContent(responseText, this._response.status, this._response.url); } break; case 'popstate': this._isPushstate = false; this.loadContent(responseText); break; case 'reload': this._isPushstate = false; this.loadContent(responseText); break; default: this.loadContent(responseText); break; } }); } /** * Request the new page using the `fetch` api. * @see https://fetch.spec.whatwg.org/ * @param href - URI of the requested page */ private doRequest(href:string):void{ // Update the current request ID this._requestId++; // Store the ID at the start of the async request const idAtStartOfRequest = this._requestId; let uri = href; const queryString = href.split('?')[1]; // Check if Pjax needs to modify the URI with a cache busting param if(this.options.cacheBust){ uri += (queryString === undefined) ? (`?cb=${Date.now()}`) : (`&cb=${Date.now()}`); } const fetchMethod = 'GET'; const fetchHeaders = new Headers({ 'X-Requested-With': 'XMLHttpRequest', 'X-Pjax': 'true' }); fetch(uri, { method: fetchMethod, headers: fetchHeaders }).then((response:Response)=>{ // Only handle the request if it's still the most recent request if(idAtStartOfRequest === this._requestId){ this.handleResponse(response); } }).catch((error:Error)=>{ if(this.options.debug){ console.group(); console.error('%c[Pjax] '+`%cFetch error:`,'color:#f3ff35','color:#eee'); console.error(error); console.groupEnd(); } }); } /** * Called when the user hovers over an element. * Handles prefetching the page request before the user clicks. * @param href - URI of the requested page */ public handlePrefetch(href:string): void{ // Don't prefetch links after the user has confirmed a page switch if(this._confirmed){ return; } if(this.options.debug){ console.log('%c[Pjax] '+`%cprefetching ${ href }`,'color:#f3ff35','color:#eee'); } // Abort any outstanding request this.abortRequest(); // Trigger the prefetch event trigger(document, ['pjax:prefetch']); this._request = 'prefetch'; this.doRequest(href); } /** * Called when a new page needs to be loaded. * @param href - URI of the reqeusted page * @param loadType - informs the response handler what type of load is being preformed (eg: prefetch) * @param el - `Element` that the triggered the page load */ public handleLoad(href:string, loadType:string, el:Element = null): void{ // If the user already confirmed the page switch abort load if(this._confirmed){ return; } // Trigger the send event trigger(document, ['pjax:send'], el); // Handle status classes this._dom.classList.remove('dom-is-loaded'); this._dom.classList.add('dom-is-loading'); // Set the page switch confirmed flag this._confirmed = true; // Check if the new page is cached if(this._cache !== null){ if(this.options.debug){ console.log('%c[Pjax] '+`%cloading cached content from ${ href }`,'color:#f3ff35','color:#eee'); } this.loadCachedContent(); } // Pjax isn't prefetching, do the request else if(this._request !== 'prefetch'){ if(this.options.debug){ console.log('%c[Pjax] '+`%cloading ${ href }`,'color:#f3ff35','color:#eee'); } this._request = loadType; this.doRequest(href); } } /** * Called when Pjax needs to clear a prefetch. */ public clearPrefetch(): void{ // Check that the user hasn't confirmed a page switch if(!this._confirmed){ // Reset the cache this._cache = null; // Abort any current requests this.abortRequest(); // Trigger the cancel event trigger(document, ['pjax:cancel']); } } /** * Manually triggers Pjax to load the requested page. * @param url - `string` */ public static load(url:string):void{ const customEvent = new CustomEvent('pjax:load', { detail:{ uri: url } }); document.dispatchEvent(customEvent); } }