diff --git a/package.json b/package.json index e07e24e..1bfe6de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pagespeed-saver", - "version": "2.0.4", + "version": "2.1.0", "description": "", "main": "index.js", "scripts": { @@ -25,6 +25,7 @@ "webpack-cli": "^4.1.0" }, "dependencies": { + "dexie": "^3.2.0", "file-saver": "^2.0.5", "slugify": "^1.6.5" } diff --git a/src/manifest.json b/src/manifest.json index 2c03648..7e628df 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "PageSpeed Saver", - "version": "2.0.4", + "version": "2.1.0", "icons": { "128": "icon128.png"}, "description": "Quickly save your PageSpeed Insights reports to JSON.", "web_accessible_resources": ["bundle.js"], diff --git a/src/script.js b/src/script.js index 2753ee7..3bdf077 100644 --- a/src/script.js +++ b/src/script.js @@ -1,26 +1,154 @@ -// PageSpeed Saver 2.0.4 -// @defaced +/** + * PageSpeed Saver 2.1.0 + * @defaced + * + * Source: https://github.com/workeffortwaste/pagespeed-saver/ + * Support: https://ko-fi.com/defaced/ + * */ (() => { + /* Init */ + const saver = {} /* Store the most recent report ids for mobile and desktop devices. */ + let rows = '' /* Store the HTML for reinjecting the previous report rows into the DOM. */ + let injecting = false /* Block multiple simulatations calls to inject previous report rows */ + let collapsed = true /* Toggle the collapsed report rows between LIMIT_COLLAPSED and LIMIT_EXPANDED */ + + const LIMIT_COLLAPSED = 5 /* Maximum number of rows to show while collapsed */ + const LIMIT_EXPANDED = 100 /* Maximum number of rows to show while expanded */ + const LIMIT_DATABASE = 100 /* Maximum number of reports to save in the database. + /* Libaries */ const FileSaver = require('file-saver') const slugify = require('slugify') + const dexie = require('dexie') + + /* Init DB */ + const db = new dexie.Dexie('PageSpeedSaver') + + /* Declare tables, IDs and indexes */ + db.version(1).stores({ + reports: '++id, url, timestamp, device, score, json' + }) - /* The HTML payload */ - const htmlButtons = ` - - + ` + /** + * CSS payload for unique styling for the HTML elements in this extension + * @returns {string} HTML + */ + const payloadStyles = ` ` + .pageSpeed_history__reports__list__item span:nth-child(5){ + color:black; + padding-right:10px; + work-break:break-all; + } + @media only screen and (max-width: 900px) { + .pageSpeed_history__reports__list__header span:last-of-type{ + display:none; + } + .pageSpeed_history__reports__list__item span:nth-child(5) { + grid-row-start: 2; + grid-column: 1 / -1; + } + .pageSpeed_history__reports__list__item span:nth-child(6) { + grid-column-start: 6; + } + .pageSpeed_history__reports__list__item span:nth-child(7) { + grid-column-start: 7; + } + } + + .pageSpeed_history__reports__list__item span:nth-child(4){ + text-transform:capitalize; + } - const htmlMenu = ` - + ` + + /** + * HTML payload for previous reports container + * @returns {string} HTML + */ + const payloadHistory = () => { + return ` +
+
+
+

Your previous reports

Download or copy to the clipboard your previous PageSpeed Insights reports.

+
+ +
+
+
Previous Reports (${collapsed ? LIMIT_COLLAPSED : LIMIT_EXPANDED})
${collapsed ? 'View More Reports' : 'View Less Reports'}
+
+
+
+ Date + Time + Perf. + Device + URL +
+
+
+ + +
` + } - /* Watch the lighthouse window object to capture changes */ - const update = async (lighthouseData) => { - if (window.__LIGHTHOUSE_JSON__?.configSettings?.formFactor === 'mobile') { - window.pageSpeedSaverMobile = lighthouseData - injectHTML('mobile') - } - if (window.__LIGHTHOUSE_JSON__?.configSettings?.formFactor === 'desktop') { - window.pageSpeedSaverDesktop = lighthouseData - injectHTML('desktop') - } + /** + * HTML template that constructs a report row for the previous reports container + * @returns {string} HTML + */ + const templateHistoryRows = (date, score, device, url, id, time) => { + return ` +
+ ${date} + ${time} + ${score} + ${device} + ${url} + Copy + Download +
` + } + + /** + * Lighthouse performance threshold helper. + * @param {int} score Lighthouse performance score + * @returns {string} Lighthouse performance text + */ + const templateHistoryHelper = (score) => { + if (score < 50) return 'poor' + if (score < 90) return 'improvement' + return 'good' + } + + /** + * Handler for incoming Lighthouse data from PageSpeed Insights. + * @param {obj} lighthouseData The Lighthouse report JSON + */ + const lighthouseWatcher = async (lighthouseData) => { + /* Trigger the button observer for the incoming device type */ + lighthouseData.configSettings.formFactor === 'desktop' ? buttonObserve('desktop') : buttonObserve('mobile') + + /* Save to history */ + const dbId = await db.reports.add({ + device: lighthouseData.configSettings.formFactor, + url: lighthouseData.finalUrl, + timestamp: lighthouseData.fetchTime, + score: (lighthouseData.categories.performance.score * 100).toFixed(0), + json: JSON.stringify(lighthouseData) + }) + + /* Delete older entries */ + limitDatabase() + + /* Add the report ID to the window object so we know which is the most recent reports */ + saver[lighthouseData.configSettings.formFactor] = dbId + + /* Get the HTML template for the new report */ + const template = templateHistoryRows(lighthouseData.fetchTime.substring(0, 10), (lighthouseData.categories.performance.score * 100).toFixed(0), lighthouseData.configSettings.formFactor, lighthouseData.finalUrl, dbId, lighthouseData.fetchTime.substring(11, 19)) + + /* Add the report to the cached history list */ + rows = template + rows + const parser = new DOMParser() + const fakeDom = parser.parseFromString(escapeHTMLPolicy.createHTML(rows), 'text/html') + fakeDom.querySelector('div:last-of-type').remove() + rows = fakeDom.body.innerHTML + + /* Update the real history list. */ + const formElement = [...document.querySelectorAll('form')].pop() + + /* Trigger a refresh by modifying the form element with a new class */ + formElement.classList.add('refresh') } + /** + * Magic to capture the incoming changes to the __LIGHTHOUSE_JSON__ window object. + * This is where PageSpeed Insights streams in its data. + */ Object.defineProperties(window, { - ___LIGHTHOUSE_JSON__: { + __LIGHTHOUSE_PROXY__: { value: {}, writable: true }, __LIGHTHOUSE_JSON__: { get: function () { - return this.___LIGHTHOUSE_JSON__ + return this.__LIGHTHOUSE_PROXY__ }, set: function (val) { - this.___LIGHTHOUSE_JSON__ = val - update(this.___LIGHTHOUSE_JSON__) + this.__LIGHTHOUSE_PROXY__ = val + lighthouseWatcher(this.__LIGHTHOUSE_PROXY__) } } }) - /* Button injection observer */ - const injectHTML = (device) => { - const num = device === 'mobile' ? 0 : 1 - const target = [...document.querySelectorAll('div[role="tabpanel"] c-wiz')].slice(-2)[num] - let toggle = false + /** + * Inject the mobile and desktop download report buttons into the DOM. + * @param {string} device The device name (mobile|desktop) + */ + const buttonInject = (device) => { + /* If the pathname for the current state doesn't contain report then return */ + if (!window.location.pathname.includes('report')) return - const callback = async (mutationsList, observer) => { - if (toggle) return; toggle = true - console.log(`Injecting PageSpeed Saver HTML (${device})`) - await sleep(1000); - // Insert button - [...document.querySelectorAll(`*[aria-labelledby="${device}_tab"] button#url_tab`)].pop().insertAdjacentHTML('afterend', escapeHTMLPolicy.createHTML(htmlButtons)) + /* Fetch the most recent native button for the device param */ + const button = [...document.querySelectorAll(`*[aria-labelledby="${device}_tab"] button#url_tab`)].pop() + + /* Return if a current sibling (our button) exists */ + if (button.nextElementSibling) { return } + + /* Inject our button */ + button.insertAdjacentHTML('afterend', escapeHTMLPolicy.createHTML(payloadButtons)) + + /* Select our injected button */ + const newButton = button.nextElementSibling - // Hook button to JSON - const button = [...document.querySelectorAll(`*[aria-labelledby="${device}_tab"] .pageSpeed_button`)].pop() + /* Attach an event to our button */ + newButton.addEventListener('click', async (e) => { + const id = saver[device] + const obj = await db.reports.get(id) + const json = JSON.parse(obj.json) - // Regex to sanitise the filename + /* Regex for filename sanitisation */ const regex = /[*+~.()'"!:@]/g - if (device === 'desktop') { - button.addEventListener('click', e => { - let filename = `${slugify(window.pageSpeedSaverDesktop.finalUrl)}-${slugify(window.pageSpeedSaverDesktop.configSettings.formFactor)}-${slugify(window.pageSpeedSaverDesktop.fetchTime)}.json` - filename = filename.replace(regex, '-') - console.log(`Saving as ${filename}`) - FileSaver.saveAs(new Blob([JSON.stringify(window.pageSpeedSaverDesktop)]), filename) - }) - } else { - button.addEventListener('click', e => { - let filename = `${slugify(window.pageSpeedSaverMobile.finalUrl)}-${slugify(window.pageSpeedSaverMobile.configSettings.formFactor)}-${slugify(window.pageSpeedSaverMobile.fetchTime)}.json` - filename = filename.replace(regex, '-') - console.log(`Saving as ${filename}`) - FileSaver.saveAs(new Blob([JSON.stringify(window.pageSpeedSaverMobile)]), filename) - }) - } + let filename = `${slugify(json.finalUrl)}-${slugify(json.configSettings.formFactor)}-${slugify(json.fetchTime)}.json` + filename = filename.replace(regex, '-') + + /* Save the file */ + FileSaver.saveAs(new Blob([JSON.stringify(json)]), filename) + }) + } + + /** + * Observe the specified tab panel for changes to automatically reinject our report buttons + * @param {string} device The device name (mobile|desktop) + */ + const buttonObserve = (device) => { + /* The most recent tab panel elements to watch for changes */ + const num = device === 'mobile' ? 0 : 1 + const target = [...document.querySelectorAll('div[role="tabpanel"] c-wiz')].slice(-2)[num] + + const callback = async (mutationsList, observer) => { + /* Destroy the observer */ observer.disconnect() + + /* A quick pause to wait for the DOM to render changes */ + await sleep(1000) + + /* Inject our button */ + buttonInject(device) } const observer = new MutationObserver(callback) observer.observe(target, { attributes: true, childList: true, subtree: true }) } - /* ES6 sleep */ + /** + * ES6 sleep + * Sleep for the specified amount of time + * @param {int} ms Time in ms + */ const sleep = (ms) => { return new Promise(resolve => setTimeout(resolve, ms)) } - /* HTML injection helper */ + /** + * HTML injection helper to allow .innerHTML to function with the documents current security settings + */ const escapeHTMLPolicy = trustedTypes.createPolicy('forceInner', { createHTML: (content) => content }) + /** + * Add the report history rows HTML to the DOM + */ + const historyInject = async () => { + /* Block concurrent calls to this function */ + if (injecting) { return } + injecting = true + + /* If our local rows HTML is not set then fetch fresh data. */ + if (!rows.length) { + /* Get all the in the database reversed and limited */ + const all = await db.reports.reverse().limit(collapsed ? LIMIT_COLLAPSED : LIMIT_EXPANDED).toArray() + all.forEach(e => { + const row = templateHistoryRows(e.timestamp.substring(0, 10), e.score, e.device, e.url, e.id, e.timestamp.substring(11, 19)) + /* Add the HTML to the rows variable. */ + rows += row + }) + } + + /* Get element */ + const historyContainer = document.querySelector('.pageSpeed_history__reports__list__header') + historyContainer.insertAdjacentHTML('afterend', escapeHTMLPolicy.createHTML(rows)) + + /* Unblock calls to this function */ + injecting = false + + /* Add events to all the download buttons */ + document.querySelectorAll('.pageSpeed_history__reports__list__item__download').forEach(e => { + e.addEventListener('click', async (event) => { + const id = e.getAttribute('data-historyid') + const obj = await db.reports.get(parseInt(id)) + const json = JSON.parse(obj.json) + + /* Regex for file sanitisation */ + const regex = /[*+~.()'"!:@]/g + + let filename = `${slugify(json.finalUrl)}-${slugify(json.configSettings.formFactor)}-${slugify(json.fetchTime)}.json` + filename = filename.replace(regex, '-') + + /* Save the file */ + FileSaver.saveAs(new Blob([JSON.stringify(json)]), filename) + }) + }) + + /* Add events all the copy buttons */ + document.querySelectorAll('.pageSpeed_history__reports__list__item__copy').forEach(e => { + e.addEventListener('click', async (event) => { + const id = e.getAttribute('data-historyid') + const obj = await db.reports.get(parseInt(id)) + /* Send the JSON to the OS clipboard */ + navigator.clipboard.writeText(obj.json) + }) + }) + } + + /** + * Observe the form element for changes to automatically reinject our previous report container. + * Also triggers the button injection to catch an edge case scenario where buttons did not get reinjected into the DOM. + */ + const formObserve = () => { + const callback = async (mutationsList, observer) => { + /* Trigger the tab panel button injections */ + buttonInject('mobile') + buttonInject('desktop') + + if (document.querySelectorAll('.pageSpeed_history').length === 0) { + const formElement = [...document.querySelectorAll('form')].pop() + formElement.insertAdjacentHTML('afterend', escapeHTMLPolicy.createHTML(payloadHistory())) + + /* Make the more button work */ + document.querySelector('.pageSpeed_history__reports__header__more').addEventListener('click', historyMore) + + /* Add the row data to the DOM */ + historyInject(formElement) + } + + if ((mutationsList.map(e => e.attributeName).includes('class'))) { + /* Remove the observer */ + observer.disconnect() + + /* Remove the current pagespeed history from the DOM */ + document.querySelector('.pageSpeed_history').remove() + + /* Reinject the history container and reports in the correct place */ + const formElement = [...document.querySelectorAll('form')].pop() + formElement.insertAdjacentHTML('afterend', escapeHTMLPolicy.createHTML(payloadHistory())) + + /* Make the more button work */ + document.querySelector('.pageSpeed_history__reports__header__more').addEventListener('click', historyMore) + + /* Add the row data to the DOM */ + historyInject(formElement) + + /* Start the form observer again to listen for changes on the correct form element */ + formObserve() + } + } + + const observer = new MutationObserver(callback) + + /* Fetch the last form element in the DOM */ + const formElement = [...document.querySelectorAll('form')].slice(-1)[0] + observer.observe(formElement, { attributes: true, childList: true, subtree: true }) + } + + /** + * Toggle the collapsed reports between the upper and lower limits. + */ + const historyMore = () => { + collapsed = !collapsed + /* Update the real history list. Removing the element to triggering our observer */ + rows = '' /* Clear the local rows obj to force a refresh from the db' */ + const formElement = [...document.querySelectorAll('form')].pop() + formElement.classList.add('refresh') + } + + /** + * Remove everything over the uppoer bounds of the database limit so it doesn't balloon in size. + */ + const limitDatabase = async () => { + await db.reports.reverse().offset(LIMIT_DATABASE).delete() + } + + document.querySelector('header > a').addEventListener('click', async (e) => { + /* Force a browser refresh rather than a JS framework refresh */ + window.location.href = 'https://pagespeed.web.dev/' + }) + + /* Add styles to head */ + document.head.insertAdjacentHTML('beforeend', escapeHTMLPolicy.createHTML(payloadStyles)) + /* Add additional menu buttons */ const link = document.querySelector('header > div') const linkOpen = link.cloneNode(true) @@ -171,5 +582,19 @@ linkDiff.querySelector('span').textContent = 'Compare Reports' link.before(linkOpen) link.before(linkDiff) - document.body.insertAdjacentHTML('beforeend', escapeHTMLPolicy.createHTML(htmlMenu)) + + /* Select the form */ + const formElement = [...document.querySelectorAll('form')].pop() + + /* Inject our history content */ + formElement.insertAdjacentHTML('afterend', escapeHTMLPolicy.createHTML(payloadHistory())) + + /* Make the more button work */ + document.querySelector('.pageSpeed_history__reports__header__more').addEventListener('click', historyMore) + + /* Add the row data to the DOM */ + historyInject(formElement) + + /* Start the form observer for the first time */ + formObserve() })()