├── .eleventy.js ├── .eleventyignore ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── _data ├── isolatedKeys.js ├── lastruns.js ├── lighthouseMaximums.js ├── sites │ ├── sample.js │ ├── ssg.js │ ├── test-runners.js │ └── zachleat.js └── urlsForApi.js ├── _includes └── layout.njk ├── api-urls.11ty.js ├── api.11ty.js ├── assets ├── chart.js ├── css.njk ├── islands.js ├── script.njk ├── sparkline.css ├── style.css └── timestamp-ago.js ├── categories.njk ├── index.njk ├── netlify.toml ├── package.json ├── plugins └── keep-data-cache │ ├── index.js │ └── manifest.yml ├── run-tests.js ├── utils ├── calc.js ├── getObjectKey.js └── sparkline.js └── zip-results.js /.eleventy.js: -------------------------------------------------------------------------------- 1 | const byteSize = require("byte-size"); 2 | const shortHash = require("short-hash"); 3 | const lodash = require("lodash"); 4 | const getObjectKey = require("./utils/getObjectKey.js"); 5 | const calc = require("./utils/calc.js"); 6 | const Sparkline = require('./utils/sparkline.js'); 7 | 8 | function isUrlMatch(haystackUrls, needleUrl) { 9 | if(!Array.isArray(haystackUrls) || haystackUrls.length === 0) { 10 | return false; 11 | } 12 | 13 | if(needleUrl && typeof needleUrl === "string") { 14 | // TODO lowercase just the origins 15 | needleUrl = needleUrl.toLowerCase(); 16 | if(haystackUrls.indexOf(needleUrl) > -1 || needleUrl.endsWith("/") && haystackUrls.indexOf(needleUrl.substr(0, needleUrl.length - 1)) > -1) { 17 | return true; 18 | } 19 | } 20 | return false; 21 | } 22 | 23 | function hasUrl(urls, { url, requestedUrl }, skipUrls = []) { 24 | // urls comes from sites[vertical].urls, all requestedUrls (may not include trailing slash) 25 | 26 | // TODO lowercase just the origins 27 | let lowercaseUrls = urls.map(targetUrl => targetUrl.toLowerCase()); 28 | 29 | if(isUrlMatch(skipUrls, requestedUrl) || isUrlMatch(skipUrls, url)) { 30 | return false; 31 | } 32 | 33 | // Requested url matches? 34 | if(isUrlMatch(lowercaseUrls, requestedUrl)) { 35 | return true; 36 | } 37 | if(isUrlMatch(lowercaseUrls, url)) { 38 | return true; 39 | } 40 | 41 | return false; 42 | } 43 | 44 | function showDigits(num, digits = 2) { 45 | let toNum = parseFloat(num); 46 | let afterFixed = toNum.toFixed(digits); 47 | return afterFixed; 48 | } 49 | 50 | function pad(num) { 51 | return (num < 10 ? "0" : "") + num; 52 | } 53 | 54 | function mapProp(prop, targetObj) { 55 | if(Array.isArray(prop)) { 56 | let otherprops = []; 57 | prop = prop.map(entry => { 58 | // TODO this only works as the first entry 59 | if(entry === ":newest") { 60 | entry = Object.keys(targetObj).sort().pop(); 61 | } else if(entry.indexOf("||") > -1) { 62 | for(let key of entry.split("||")) { 63 | if(lodash.get(targetObj, [...otherprops, key])) { 64 | entry = key; 65 | break; 66 | } 67 | } 68 | } 69 | otherprops.push(entry); 70 | 71 | return entry; 72 | }); 73 | } 74 | 75 | return prop; 76 | } 77 | 78 | function getLighthouseTotal(entry) { 79 | return entry.lighthouse.performance * 100 + 80 | entry.lighthouse.accessibility * 100 + 81 | entry.lighthouse.bestPractices * 100 + 82 | entry.lighthouse.seo * 100; 83 | } 84 | 85 | module.exports = function(eleventyConfig) { 86 | eleventyConfig.addFilter("shortHash", shortHash); 87 | eleventyConfig.setServerOptions({ 88 | domDiff: false 89 | }); 90 | 91 | eleventyConfig.addFilter("repeat", function(str, times) { 92 | let result = ''; 93 | 94 | for (let i = 0; i < times; i++) { 95 | result += str; 96 | } 97 | 98 | return result; 99 | }); 100 | 101 | // first ${num} entries (and the last entry too) 102 | eleventyConfig.addFilter("headAndLast", function(arr, num) { 103 | if(num && num < arr.length) { 104 | let newArr = arr.slice(0, num); 105 | newArr.push(arr[arr.length - 1]); 106 | return newArr; 107 | } 108 | return arr; 109 | }); 110 | 111 | eleventyConfig.addFilter("displayUrl", function(url, keepWww = false) { 112 | if(!keepWww) { 113 | url = url.replace("https://www.", ""); 114 | } 115 | url = url.replace("https://", ""); 116 | if(url.endsWith("/index.html")) { 117 | url = url.replace("/index.html", "/"); 118 | } 119 | return url; 120 | }); 121 | 122 | eleventyConfig.addFilter("showDigits", function(num, digits) { 123 | return showDigits(num, digits); 124 | }); 125 | 126 | eleventyConfig.addFilter("displayTime", function(time) { 127 | let num = parseFloat(time); 128 | if(num > 850) { 129 | return `${showDigits(num / 1000, 2)}s`; 130 | } 131 | return `${showDigits(num, 0)}ms`; 132 | }); 133 | 134 | eleventyConfig.addFilter("displayFilesize", function(size) { 135 | let normalizedSize = byteSize(size, { units: 'iec', precision: 0 }); 136 | let unit = normalizedSize.unit; 137 | let value = normalizedSize.value; 138 | return `${value}${unit.substr(0,1)} ${unit}`; 139 | }); 140 | 141 | eleventyConfig.addFilter("displayDate", function(timestamp) { 142 | let months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; 143 | let date = new Date(timestamp); 144 | let day = `${months[date.getMonth()]} ${pad(date.getDate())}`; 145 | return `${day} ${pad(date.getHours())}:${pad(date.getMinutes())}`; 146 | }); 147 | 148 | eleventyConfig.addFilter("sortCumulativeScore", (obj) => { 149 | return obj.sort((a, b) => { 150 | 151 | let newestKeyA = Object.keys(a).sort().pop(); 152 | let newestKeyB = Object.keys(b).sort().pop(); 153 | 154 | // Lighthouse error 155 | // e.g. { url: 'https://mangoweb.net/', error: 'Unknown error.' } 156 | if(b[newestKeyB].error && a[newestKeyA].error) { 157 | return 0; 158 | } else if(b[newestKeyB].error) { 159 | return -1; 160 | } else if(a[newestKeyA].error) { 161 | return 1; 162 | } 163 | 164 | // lower is better 165 | return a[newestKeyA].ranks.cumulative - b[newestKeyB].ranks.cumulative; 166 | }); 167 | }); 168 | 169 | // Works with arrays too 170 | // Sort an object that has `order` props in values. 171 | // If prop is not passed in, sorts by object keys 172 | // Returns an array 173 | eleventyConfig.addFilter("sort", (obj, prop = "___key") => { 174 | let arr; 175 | let defaultKey = "___key"; 176 | if(Array.isArray(obj)) { 177 | arr = obj; 178 | } else { 179 | arr = []; 180 | 181 | for(let key in obj) { 182 | if(prop === defaultKey) { 183 | obj[key][defaultKey] = key; 184 | } 185 | arr.push(obj[key]); 186 | } 187 | } 188 | 189 | let sorted = arr.sort((a, b) => { 190 | let aVal = lodash.get(a, mapProp(prop, a)); 191 | let bVal = lodash.get(b, mapProp(prop, b)); 192 | if(aVal > bVal) { 193 | return -1; 194 | } 195 | if(aVal < bVal) { 196 | return 1; 197 | } 198 | return 0; 199 | }); 200 | 201 | if(!Array.isArray(obj)) { 202 | if(prop === defaultKey) { 203 | for(let entry of sorted) { 204 | delete entry[defaultKey]; 205 | } 206 | } 207 | } 208 | 209 | return sorted; 210 | }); 211 | 212 | eleventyConfig.addFilter("getObjectKey", getObjectKey); 213 | 214 | function filterResultsToUrls(obj, urls = [], skipKeys = [], skipUrls = []) { 215 | let arr = []; 216 | for(let key in obj) { 217 | if(skipKeys.indexOf(key) > -1) { 218 | continue; 219 | } 220 | 221 | let result; 222 | let newestFilename = Object.keys(obj[key]).sort().pop(); 223 | result = obj[key][newestFilename]; 224 | // urls comes from sites[vertical].urls, all requestedUrls (may not include trailing slash) 225 | if(urls === true || result && hasUrl(urls, result, skipUrls)) { 226 | arr.push(obj[key]); 227 | } 228 | } 229 | return arr; 230 | } 231 | 232 | eleventyConfig.addFilter("getSites", (results, sites, vertical, skipKeys = []) => { 233 | let urls = sites[vertical].urls; 234 | let skipUrls = sites[vertical].skipUrls; 235 | let isIsolated = sites[vertical].options && sites[vertical].options.isolated === true; 236 | let prunedResults = isIsolated ? results[vertical] : results; 237 | return filterResultsToUrls(prunedResults, urls, skipKeys, skipUrls); 238 | }); 239 | 240 | // Deprecated, use `getSites` instead, it works with isolated categories 241 | eleventyConfig.addFilter("filterToUrls", filterResultsToUrls); 242 | 243 | eleventyConfig.addFilter("hundoCount", (entry) => { 244 | let count = 0; 245 | if(entry.lighthouse.performance === 1) { 246 | count++; 247 | } 248 | if(entry.lighthouse.accessibility === 1) { 249 | count++; 250 | } 251 | if(entry.lighthouse.bestPractices === 1) { 252 | count++; 253 | } 254 | if(entry.lighthouse.seo === 1) { 255 | count++; 256 | } 257 | 258 | return count; 259 | }); 260 | 261 | eleventyConfig.addFilter("notGreenCircleCount", (entry) => { 262 | let count = 0; 263 | if(entry.lighthouse.performance < .9) { 264 | count++; 265 | } 266 | if(entry.lighthouse.accessibility < .9) { 267 | count++; 268 | } 269 | if(entry.lighthouse.bestPractices < .9) { 270 | count++; 271 | } 272 | if(entry.lighthouse.seo < .9) { 273 | count++; 274 | } 275 | 276 | return count; 277 | }); 278 | 279 | eleventyConfig.addFilter("hundoCountTotals", (counts, entry) => { 280 | if(!entry.error && !isNaN(entry.lighthouse.performance)) { 281 | counts.total++; 282 | } 283 | 284 | if(entry.lighthouse.performance === 1) { 285 | counts.performance++; 286 | } 287 | if(entry.lighthouse.accessibility === 1) { 288 | counts.accessibility++; 289 | } 290 | if(entry.lighthouse.bestPractices === 1) { 291 | counts.bestPractices++; 292 | } 293 | if(entry.lighthouse.seo === 1) { 294 | counts.seo++; 295 | } 296 | 297 | if(entry.lighthouse.performance === 1 && entry.lighthouse.accessibility === 1 && entry.lighthouse.bestPractices === 1 && entry.lighthouse.seo === 1) { 298 | counts.perfect++; 299 | } 300 | 301 | return counts; 302 | }); 303 | 304 | eleventyConfig.addFilter("lighthouseTotal", getLighthouseTotal); 305 | 306 | eleventyConfig.addFilter("addLighthouseTotals", (arr) => { 307 | /* special case */ 308 | for(let obj of arr) { 309 | for(let entry in obj) { 310 | if(obj[entry].lighthouse) { 311 | obj[entry].lighthouse[":lhtotal"] = getLighthouseTotal(obj[entry]); 312 | } 313 | } 314 | } 315 | return arr; 316 | }); 317 | 318 | eleventyConfig.addFilter("toJSON", function(obj) { 319 | return JSON.stringify(obj); 320 | }); 321 | 322 | eleventyConfig.addFilter("calc", calc); 323 | 324 | eleventyConfig.addFilter("generatorImageUrl", (url) => { 325 | return `https://v1.generator.11ty.dev/image/${encodeURIComponent(url)}/`; 326 | }); 327 | 328 | eleventyConfig.addFilter("hostingImageUrl", (url) => { 329 | // return `https://v1--eleventy-api-built-with.netlify.app/${encodeURIComponent(url)}/image/host/`; 330 | return `https://v1.builtwith.11ty.dev/${encodeURIComponent(url)}/image/host/`; 331 | }); 332 | 333 | eleventyConfig.addPairedShortcode("starterMessage", (htmlContent) => { 334 | if(process.env.SITE_NAME !== "speedlify") { 335 | return htmlContent; 336 | } 337 | return ""; 338 | }); 339 | 340 | // Assets 341 | eleventyConfig.addPassthroughCopy({ 342 | "./node_modules/chartist/dist/chartist.js": "chartist.js", 343 | "./node_modules/chartist/dist/chartist.css.map": "chartist.css.map", 344 | }); 345 | 346 | eleventyConfig.addWatchTarget("./assets/"); 347 | 348 | eleventyConfig.setBrowserSyncConfig({ 349 | ui: false, 350 | ghostMode: false 351 | }); 352 | eleventyConfig.addShortcode('lighthouseSparkline', (site) => { 353 | const timeSeries = Object.values(site).sort( 354 | (a, b) => a.timestamp - b.timestamp 355 | ); 356 | const values = timeSeries.map((run) => run.lighthouse?.total || 0); 357 | return Sparkline({ 358 | // red-orange-green gradient similar to usage in 359 | gradient: [ 360 | { color: '#ff4e42', offset: '0%' }, 361 | { color: '#ff4e42', offset: '30%' }, 362 | { color: '#ffa400', offset: '70%' }, 363 | { color: '#ffa400', offset: '85%' }, 364 | { color: '#0cce6b', offset: '95%' }, 365 | { color: '#0cce6b', offset: '100%' }, 366 | ], 367 | values, 368 | min: 0, 369 | max: 400, 370 | timeSeries, 371 | }); 372 | }); 373 | 374 | eleventyConfig.addShortcode('weightSparkline', (site) => { 375 | const timeSeries = Object.values(site).sort( 376 | (a, b) => a.timestamp - b.timestamp 377 | ); 378 | const values = timeSeries.map((run) => run.weight?.total || 0); 379 | return Sparkline({ 380 | color: '#d151ff', 381 | values, 382 | min: 0, 383 | timeSeries, 384 | // Display raw bytes as pretty values on y axis, e.g. 49244 => 48K 385 | formatAxis: (num) => { 386 | const { value, unit } = byteSize(num, { units: 'iec', precision: 0 }); 387 | return value === '0' ? value : value + unit.slice(0, 1); 388 | }, 389 | }); 390 | }); 391 | 392 | eleventyConfig.setWatchJavaScriptDependencies(false); 393 | }; 394 | -------------------------------------------------------------------------------- /.eleventyignore: -------------------------------------------------------------------------------- 1 | README.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .log 3 | _site 4 | _data/results/* 5 | _data/results-last-runs.json 6 | package-lock.json 7 | .env 8 | .cache -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Zach Leatherman @zachleat 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 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, 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # speedlify 2 | 3 | After you make a fast web site, keep it fast by measuring it over time. Read [Use Speedlify to Continuously Measure Site Performance](https://www.zachleat.com/web/speedlify/). Created by [@zachleat](https://www.zachleat.com/). 4 | 5 | * Requires Node 12+ 6 | * Each file in `_data/sites/*.js` is a category and contains a list of sites for comparison. 7 | 8 | ## Run locally 9 | 10 | _After cloning you’ll probably want to delete the initial `_data/sites/*.js` files and create your own file with a list of your own site URLs!_ 11 | 12 | ``` 13 | npm install 14 | npm run test-pages 15 | npm run start 16 | ``` 17 | 18 | ## Related 19 | 20 | * [The Eleventy Leaderboards](https://www.zachleat.com/web/eleventy-leaderboard-speedlify/) are running on Speedlify 21 | * [speedlify.dev](https://www.speedlify.dev/) shows some sample categories 22 | * Use the [`` component](https://github.com/zachleat/speedlify-score) to show your scores on your page. Read more at [I added Lighthouse Scores to my Site’s Footer and You Can Too](https://www.zachleat.com/web/lighthouse-in-footer/) 23 | * The [Eleventy Starter Projects list](https://www.11ty.dev/docs/starter/) shows Lighthouse scores from Speedlify. Read more at [The Lighthouse Scores Will Continue Until Morale Improves](https://www.zachleat.com/web/11ty-lighthouse/). 24 | 25 | ## Deploy to Netlify 26 | 27 | Can run directly on Netlify (including your tests) and will save the results to a Netlify build cache (via Netlify Build Plugins, see `plugins/keep-data-cache/`). 28 | 29 | _After cloning you’ll probably want to delete the initial `_data/sites/*.js` files and create your own file with a list of your own site URLs!_ 30 | 31 | 32 | 33 | Speedlify will also save your data to `/results.zip` so that you can download later. Though this has proved to be unnecessary so far, it does serve as a fallback backup mechanism in case the Netlify cache is lost. Just look up your previous build URL and download the data to restore. 34 | 35 | [![Netlify Status](https://api.netlify.com/api/v1/badges/7298a132-e366-460a-a4da-1ea352a4e790/deploy-status)](https://app.netlify.com/sites/speedlify/deploys) 36 | 37 | * **Run every day or week**: You can set Speedlify to run at a specified interval using a Netlify Build Hook, read more on the Eleventy docs: [Quick Tip #008—Trigger a Netlify Build Every Day with IFTTT](https://www.11ty.dev/docs/quicktips/netlify-ifttt/). 38 | 39 | ## Known Limitations 40 | 41 | * If you change a URL to remove a redirect (to remove or add a `www.`, moved domains, etc), you probably want to delete the old URL’s data otherwise you’ll have two entries in the results list. 42 | * When running on Netlify, a single category has a max limit on the number of sites it can test, upper bound on how many tests it can complete in the 15 minute Netlify build limit. 43 | * The same URL cannot be listed in two different categories (yet). 44 | 45 | ## Pay for something better 46 | 47 | Speedlify is intended as a stepping stone to more robust performance monitoring solutions like: 48 | 49 | * [SpeedCurve](https://speedcurve.com/) 50 | * [Calibre](https://calibreapp.com/) 51 | * [DebugBear](https://www.debugbear.com/) -------------------------------------------------------------------------------- /_data/isolatedKeys.js: -------------------------------------------------------------------------------- 1 | const shortHash = require("short-hash"); 2 | const fastglob = require("fast-glob"); 3 | 4 | module.exports = async function() { 5 | let categories = await fastglob("./_data/sites/*.js", { 6 | caseSensitiveMatch: false 7 | }); 8 | 9 | let isolated = new Set(); 10 | for(let file of categories) { 11 | let category = file.split("/").pop().replace(/\.js$/, ""); 12 | let categoryData = require(`./sites/${category}.js`); 13 | if(typeof categoryData === "function") { 14 | categoryData = await categoryData(); 15 | } 16 | 17 | if(categoryData.options && categoryData.options.isolated) { 18 | isolated.add(category); 19 | } 20 | } 21 | 22 | return Array.from(isolated); 23 | }; -------------------------------------------------------------------------------- /_data/lastruns.js: -------------------------------------------------------------------------------- 1 | // Nunjucks workaround for dashes in global `results-last-runs.json` variable name 2 | 3 | let lastruns = {}; 4 | 5 | try { 6 | lastruns = require("./results-last-runs.json"); 7 | } catch(e) { 8 | } 9 | 10 | module.exports = lastruns; -------------------------------------------------------------------------------- /_data/lighthouseMaximums.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | average: { 3 | fcp: 4000, 4 | si: 5800, 5 | lcp: 4000, 6 | tti: 7300, 7 | tbt: 600, 8 | cls: 0.25, 9 | }, 10 | good: { 11 | fcp: 2336, 12 | si: 3387, 13 | lcp: 2500, 14 | tti: 3875, 15 | tbt: 287, 16 | cls: 0.1, 17 | // fid: 130, 18 | }, 19 | 20 | }; -------------------------------------------------------------------------------- /_data/sites/sample.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "Sample", // optional, falls back to object key 3 | description: "The default sites that get tested", 4 | options: { 5 | runs: 1, 6 | frequency: 1, // (in minutes) 7 | }, 8 | urls: [ 9 | "https://www.speedlify.dev/" 10 | ] 11 | }; -------------------------------------------------------------------------------- /_data/sites/ssg.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "SSG", // optional, falls back to object key 3 | description: "Site Generator web sites", 4 | // skip if localhost 5 | // skip if this is a new fork of the speedlify (not Zach’s) 6 | skip: !process.env.CONTEXT || process.env.SITE_NAME !== "speedlify", 7 | options: { 8 | frequency: 60 * 23, // 24 hours 9 | // Use "run" if the sites don’t share assets on the same origin 10 | // and we can reset chrome with each run instead of 11 | // each site in every run (it’s faster) 12 | // Use "site" if sites are all on the same origin and share assets. 13 | }, 14 | urls: [ 15 | "https://www.11ty.dev/", 16 | "https://www.gatsbyjs.com/", 17 | "https://gohugo.io/", 18 | "https://nextjs.org/", 19 | "https://nuxt.com/", 20 | "https://gridsome.org/", 21 | "https://vuepress.vuejs.org/", 22 | "https://docusaurus.io/", 23 | "https://astro.build/", 24 | "https://jekyllrb.com/", 25 | "https://hexo.io/", 26 | "https://svelte.dev/", 27 | "https://remix.run/", 28 | "https://record-collector.net/", 29 | "https://www.solidjs.com/", 30 | "https://lume.land/", 31 | ], 32 | skipUrls: [ 33 | "https://hexo.io/zh-cn/", 34 | ] 35 | }; -------------------------------------------------------------------------------- /_data/sites/test-runners.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | description: "Front-end Testing Tools", 3 | // skip if localhost 4 | // skip if this is a new fork of the speedlify (not Zach’s) 5 | skip: !process.env.CONTEXT || process.env.SITE_NAME !== "speedlify", 6 | options: { 7 | frequency: 60 * 11 + 30, // 11h, 30m 8 | // Use "run" if the sites don’t share assets on the same origin 9 | // and we can reset chrome with each run instead of 10 | // each site in every run (it’s faster) 11 | // Use "site" if sites are all on the same origin and share assets. 12 | freshChrome: "site", 13 | }, 14 | urls: [ 15 | "https://eslint.org/", 16 | "https://qunitjs.com/", 17 | "https://karma-runner.github.io/latest/index.html", 18 | "https://gulpjs.com/", 19 | "https://webhint.io/", 20 | "https://gruntjs.com/", 21 | "https://theintern.io/", 22 | "https://istanbul.js.org/", 23 | "https://webdriver.io/", 24 | "https://mochajs.org/" 25 | ] 26 | }; -------------------------------------------------------------------------------- /_data/sites/zachleat.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "zachleat.com", // optional, falls back to object key 3 | description: "Zach’s Personal web site", 4 | // skip if localhost 5 | // skip if this is a new fork of the speedlify (not Zach’s) 6 | skip: !process.env.CONTEXT || process.env.SITE_NAME !== "speedlify", 7 | options: { 8 | frequency: 60 * 23, // 23 hours 9 | // Use "run" if the sites don’t share assets on the same origin 10 | // and we can reset chrome with each run instead of 11 | // each site in every run (it’s faster) 12 | // Use "site" if sites are all on the same origin and share assets. 13 | freshChrome: "site" 14 | }, 15 | urls: [ 16 | "https://www.zachleat.com/", 17 | "https://www.zachleat.com/about/", 18 | "https://www.zachleat.com/web/", 19 | "https://www.zachleat.com/web/fonts/", 20 | "https://www.zachleat.com/web/eleventy/", 21 | "https://www.zachleat.com/resume/", 22 | "https://www.zachleat.com/twitter/", 23 | // Popular Posts 24 | "https://www.zachleat.com/web/lighthouse-in-footer/", 25 | "https://www.zachleat.com/web/speedlify/", 26 | "https://www.zachleat.com/web/comprehensive-webfonts/", 27 | "https://www.zachleat.com/web/google-fonts-display/", 28 | ] 29 | }; -------------------------------------------------------------------------------- /_data/urlsForApi.js: -------------------------------------------------------------------------------- 1 | const fastglob = require("fast-glob"); 2 | 3 | async function getCategoryToUrlMap() { 4 | let categories = await fastglob("./_data/sites/*.js", { 5 | caseSensitiveMatch: false 6 | }); 7 | 8 | let map = {}; 9 | for(let file of categories) { 10 | let categoryName = file.split("/").pop().replace(/\.js$/, ""); 11 | map[categoryName] = []; 12 | 13 | let categoryData = require(`./sites/${categoryName}.js`); 14 | if(typeof categoryData === "function") { 15 | categoryData = await categoryData(); 16 | } 17 | // TODO lowercase just the origin? 18 | map[categoryName] = categoryData.urls.map(url => url.toLowerCase()); 19 | } 20 | 21 | return map; 22 | } 23 | 24 | function getCategoryList(map, url) { 25 | let categories = new Set(); 26 | for(let categoryName in map) { 27 | if(map[categoryName].indexOf(url.toLowerCase())) { 28 | categories.add(categoryName); 29 | } 30 | } 31 | return Array.from(categories); 32 | } 33 | 34 | module.exports = async function() { 35 | let categoryMap = await getCategoryToUrlMap(); 36 | 37 | let resultFiles = await fastglob("./_data/results/**/*.json", { 38 | caseSensitiveMatch: false 39 | }); 40 | 41 | let sites = {}; 42 | 43 | // TODO api JSON ranks should be dependent on category 44 | for(let resultFile of resultFiles) { 45 | let split = resultFile.split("/"); 46 | let filename = split.pop(); 47 | let hash = split.pop(); 48 | 49 | let resultData = require(`.${resultFile}`); 50 | 51 | let categories = []; 52 | if(resultData.requestedUrl) { 53 | categories = getCategoryList(categoryMap, resultData.requestedUrl); 54 | } 55 | 56 | sites[hash] = { 57 | requestedUrl: resultData.requestedUrl, 58 | url: resultData.url, 59 | // based on final URL not requested URL 60 | hash: hash, 61 | categories: categories, 62 | // Deprecated 63 | vertical: categories[0], 64 | }; 65 | } 66 | return Object.values(sites); 67 | }; -------------------------------------------------------------------------------- /_includes/layout.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Speedlify 7 | 8 | 9 | 10 | 13 | 14 | 15 |
16 |

speedlify{% if vertical %}:{{ vertical }}{% endif %}

17 |

18 | Benchmark {{ sites[vertical].description or "web pages" }} over time. 19 | {%- if sites[vertical].unordered %} 20 | Results for this category are unordered. 21 | {%- endif %} 22 | {%- if maxResults %} 23 | Updates at most once every {{ sites[vertical].options.frequency or 60 }} minutes. Only the last {{ maxResults }} run{% if maxResults != 1 %}s{% endif %} (and the oldest run) are shown. 24 | {%- endif %} 25 | Created by @zachleat. 26 |

27 |

28 | Deploy your own instance of Speedlify and test your web sites. 29 |

30 |
31 | 32 |
33 | {{ content | safe }} 34 |
35 | 36 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /api-urls.11ty.js: -------------------------------------------------------------------------------- 1 | const shortHash = require("short-hash"); 2 | 3 | class ApiUrls { 4 | data() { 5 | return { 6 | layout: false, 7 | permalink: function(data) { 8 | return `/api/urls.json`; 9 | } 10 | }; 11 | } 12 | 13 | render(data) { 14 | let resultData = {}; 15 | for(let urlData of data.urlsForApi) { 16 | resultData[urlData.requestedUrl] = urlData; 17 | } 18 | return JSON.stringify(resultData, null, 2); 19 | } 20 | } 21 | 22 | module.exports = ApiUrls; -------------------------------------------------------------------------------- /api.11ty.js: -------------------------------------------------------------------------------- 1 | const getObjectKey = require("./utils/getObjectKey.js"); 2 | 3 | class ApiEntry { 4 | async data() { 5 | return { 6 | layout: false, 7 | pagination: { 8 | size: 1, 9 | data: "urlsForApi", 10 | alias: "site" 11 | }, 12 | permalink: function(data) { 13 | return `/api/${data.site.hash}.json`; 14 | } 15 | }; 16 | } 17 | 18 | render(data) { 19 | let resultSet = data.results[data.site.hash]; 20 | if(!resultSet) { 21 | // TODO better error message here, returns `false` string 22 | return false; 23 | } 24 | 25 | let newestKey = getObjectKey(resultSet, ":newest"); 26 | if(!newestKey) { 27 | // TODO better error message here, returns `false` string 28 | return false; 29 | } 30 | 31 | let secondNewestKey = getObjectKey(resultSet, ":secondnewest"); 32 | if(resultSet[secondNewestKey]) { 33 | resultSet[newestKey].previousRanks = resultSet[secondNewestKey].ranks; 34 | } 35 | return JSON.stringify(resultSet[newestKey], null, 2); 36 | } 37 | } 38 | 39 | module.exports = ApiEntry; -------------------------------------------------------------------------------- /assets/chart.js: -------------------------------------------------------------------------------- 1 | function makeTable(table) { 2 | let labels = []; 3 | let series = []; 4 | 5 | let rows = Array.from(table.querySelectorAll(":scope tbody tr")); 6 | let minY = 90; 7 | let maxY = 100; 8 | rows = rows.reverse(); 9 | 10 | for(let row of rows) { 11 | let label = row.children[0].innerText.split(" "); 12 | labels.push(label.slice(0,2).join(" ")); 13 | let childCount = row.children.length - 1; 14 | let seriesIndex = 0; 15 | for(let j = 0, k = childCount; j { 25 | this.readyResolve = resolve; 26 | this.readyReject = reject; 27 | }); 28 | } 29 | 30 | static getParents(el, selector) { 31 | let nodes = []; 32 | while(el) { 33 | if(el.matches && el.matches(selector)) { 34 | nodes.push(el); 35 | } 36 | el = el.parentNode; 37 | } 38 | return nodes; 39 | } 40 | 41 | static async ready(el) { 42 | let parents = Island.getParents(el, Island.tagName); 43 | let imports = await Promise.all(parents.map(el => el.wait())); 44 | 45 | // return innermost module import 46 | if(imports.length) { 47 | return imports[0]; 48 | } 49 | } 50 | 51 | async forceFallback() { 52 | let prefix = "is-island-waiting--"; 53 | let extraSelector = this.fallback ? this.fallback : ""; 54 | // Reverse here as a cheap way to get the deepest nodes first 55 | let components = Array.from(this.querySelectorAll(`:not(:defined)${extraSelector ? `,${extraSelector}` : ""}`)).reverse(); 56 | let promises = []; 57 | 58 | // with thanks to https://gist.github.com/cowboy/938767 59 | for(let node of components) { 60 | if(!node.isConnected || node.localName === Island.tagName) { 61 | continue; 62 | } 63 | 64 | // assign this before we remove it from the document 65 | let readyP = Island.ready(node); 66 | 67 | // Special case for img just removes the src to preserve aspect ratio while loading 68 | if(node.localName === "img") { 69 | let attr = prefix + "src"; 70 | // remove 71 | node.setAttribute(attr, node.getAttribute("src")); 72 | node.setAttribute("src", `data:image/svg+xml,`); 73 | 74 | promises.push(readyP.then(() => { 75 | // restore 76 | node.setAttribute("src", node.getAttribute(attr)); 77 | node.removeAttribute(attr); 78 | })); 79 | } else { // everything else renames the tag 80 | // remove from document to prevent web component init 81 | let cloned = document.createElement(prefix + node.localName); 82 | for(let attr of node.getAttributeNames()) { 83 | cloned.setAttribute(attr, node.getAttribute(attr)); 84 | } 85 | 86 | let children = Array.from(node.childNodes); 87 | for(let child of children) { 88 | cloned.append(child); // Keep the *same* child nodes, clicking on a details->summary child should keep the state of that child 89 | } 90 | node.replaceWith(cloned); 91 | 92 | promises.push(readyP.then(() => { 93 | // restore children (not cloned) 94 | for(let child of Array.from(cloned.childNodes)) { 95 | node.append(child); 96 | } 97 | cloned.replaceWith(node); 98 | })); 99 | } 100 | } 101 | 102 | return promises; 103 | } 104 | 105 | wait() { 106 | return this.ready; 107 | } 108 | 109 | getConditions() { 110 | let map = {}; 111 | for(let key of Object.keys(this.conditionMap)) { 112 | if(this.hasAttribute(`on:${key}`)) { 113 | map[key] = this.getAttribute(`on:${key}`); 114 | } 115 | } 116 | 117 | return map; 118 | } 119 | 120 | async connectedCallback() { 121 | this.fallback = this.getAttribute(this.attrs.fallback) 122 | this.autoInitType = this.getAttribute(this.attrs.autoInitType); 123 | 124 | // Keep fallback content without initializing the components 125 | // TODO improvement: only run this for not-eager components? 126 | await this.forceFallback(); 127 | 128 | await this.hydrate(); 129 | } 130 | 131 | getInitScripts() { 132 | return this.querySelectorAll(`:scope script[type="${this.attrs.scriptType}"]`); 133 | } 134 | 135 | getTemplates() { 136 | return this.querySelectorAll(`:scope template[${this.attrs.template}]`); 137 | } 138 | 139 | replaceTemplates(templates) { 140 | // replace