├── 11ty-demo.html ├── README.md ├── demo.html ├── package.json └── speedlify-score.js /11ty-demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # speedlify-score Web Component 2 | 3 | * [I added Lighthouse Scores to my Site’s Footer and You Can Too](https://www.zachleat.com/web/lighthouse-in-footer/) 4 | * Related blog post: [Use Speedlify to Continuously Measure Site Performance](https://www.zachleat.com/web/speedlify/) 5 | 6 | ## Demo 7 | 8 | * [`` Demo](https://zachleat.github.io/speedlify-score/demo.html) using [speedlify.dev instance](https://www.speedlify.dev/) 9 | 10 | ## Usage 11 | 12 | ### Installation 13 | 14 | ``` 15 | npm install speedlify-score 16 | ``` 17 | 18 | ### Include Sources 19 | 20 | Include `speedlify-score.js` in your page (preferably concatenated in via a build script). 21 | 22 | ### Use Markup 23 | 24 | Use `` in your markup. 25 | 26 | Required attributes: 27 | 28 | * `speedlify-url`: **Required**. The URL to your Speedlify instance. 29 | * `hash`: **Preferred** but technically optional. A hash representing the active URL. 30 | * Look this up via your Speedlify instance’s `/api/urls.json` file. [Full instructions available at this blog post](https://www.zachleat.com/web/lighthouse-in-footer/#adding-this-to-your-eleventy-site!). 31 | * `url`: Optional. Not used if `hash` is supplied. This is the raw URL of the page you’d like to see the score for. Defaults to the current page. 32 | 33 | #### Examples 34 | 35 | ```html 36 | 37 | 38 | ``` 39 | 40 | ```html 41 | 42 | 43 | ``` 44 | 45 | #### Use Attributes to customize output 46 | 47 | * If no attributes are used, it `score` is implicit and default. 48 | * If some attributes are in play, you must explicitly add `score`. 49 | * `requests` 50 | * `weight` 51 | * `rank` 52 | * `rank-change` (difference between old and new rank) 53 | 54 | ## Changelog 55 | 56 | * `v1.0.0`: First release 57 | * `v2.0.0`: Changes default render behavior (only shows Lighthouse scores by default, summary and weight are not). Adds feature to use attributes to customize output if you want to opt-in to more. 58 | * `v3.0.0`: Removes `flex-wrap: wrap` for top level component. 59 | * `v4.0.0`: Using Shadow DOM, removes need for external stylesheet. -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | 14 | 15 |

Speedlify Score Demos

16 |

Go back to source code.

17 |

With hash

18 |

19 |

With circle color override

20 |

21 |

With font size scaling

22 |

23 |

24 |

Using attributes to customize output

25 |

No requests, using rank-url

26 |

27 |

No attributes (default behavior)

28 |

29 |

No scores

30 |

31 |

All attributes

32 |

33 |

With `url` (Slower)

34 |

35 |

36 |

Without `url` or `hash` (Slower), uses current page

37 |

38 |

Put a link around it

39 | 40 |

Raw Data (you import it yourself as part of the build)

41 | 42 |

Error state

43 | 44 | 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "speedlify-score", 3 | "version": "4.0.4", 4 | "description": "A web component to show Lighthouse scores via Speedlify", 5 | "main": "speedlify-score.js", 6 | "scripts": { 7 | "start": "npx http-server ." 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/zachleat/speedlify-score.git" 12 | }, 13 | "keywords": [], 14 | "author": { 15 | "name": "Zach Leatherman", 16 | "email": "zachleatherman@gmail.com", 17 | "url": "https://zachleat.com/" 18 | }, 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/zachleat/speedlify-score/issues" 22 | }, 23 | "homepage": "https://github.com/zachleat/speedlify-score#readme" 24 | } 25 | -------------------------------------------------------------------------------- /speedlify-score.js: -------------------------------------------------------------------------------- 1 | class SpeedlifyUrlStore { 2 | constructor() { 3 | this.fetches = {}; 4 | this.responses = {}; 5 | this.urls = {}; 6 | } 7 | 8 | static normalizeUrl(speedlifyUrl, path) { 9 | let host = `${speedlifyUrl}${speedlifyUrl.endsWith("/") ? "" : "/"}` 10 | return host + (path.startsWith("/") ? path.substr(1) : path); 11 | } 12 | 13 | async fetchFromApi(apiUrl) { 14 | if(!this.fetches[apiUrl]) { 15 | this.fetches[apiUrl] = fetch(apiUrl); 16 | } 17 | 18 | let response = await this.fetches[apiUrl]; 19 | if(!this.responses[apiUrl]) { 20 | this.responses[apiUrl] = response.json(); 21 | } 22 | let json = await this.responses[apiUrl]; 23 | return json; 24 | } 25 | 26 | async fetchHash(speedlifyUrl, url) { 27 | if(this.urls[speedlifyUrl]) { 28 | return this.urls[speedlifyUrl][url] ? this.urls[speedlifyUrl][url].hash : false; 29 | } 30 | 31 | let apiUrl = SpeedlifyUrlStore.normalizeUrl(speedlifyUrl, "api/urls.json"); 32 | let json = await this.fetchFromApi(apiUrl); 33 | 34 | return json[url] ? json[url].hash : false; 35 | } 36 | 37 | async fetchData(speedlifyUrl, hash) { 38 | let apiUrl = SpeedlifyUrlStore.normalizeUrl(speedlifyUrl, `api/${hash}.json`); 39 | return this.fetchFromApi(apiUrl); 40 | } 41 | } 42 | 43 | // Global store 44 | const urlStore = new SpeedlifyUrlStore(); 45 | 46 | class SpeedlifyScore extends HTMLElement { 47 | static register(tagName) { 48 | customElements.define(tagName || "speedlify-score", SpeedlifyScore); 49 | } 50 | 51 | static attrs = { 52 | url: "url", 53 | speedlifyUrl: "speedlify-url", 54 | hash: "hash", 55 | rawData: "raw-data", 56 | requests: "requests", 57 | weight: "weight", 58 | rank: "rank", 59 | rankChange: "rank-change", 60 | score: "score", 61 | } 62 | 63 | static css = ` 64 | :host { 65 | --_circle: var(--speedlify-circle); 66 | display: flex; 67 | align-items: center; 68 | gap: 0.375em; /* 6px /16 */ 69 | } 70 | .circle { 71 | font-size: 0.8125em; /* 13px /16 */ 72 | min-width: 2.6em; 73 | height: 2.6em; 74 | line-height: 1; 75 | display: inline-flex; 76 | align-items: center; 77 | justify-content: center; 78 | border-radius: 50%; 79 | border: 0.15384615em solid currentColor; /* 2px /13 */ 80 | color: var(--_circle, #666); 81 | } 82 | .circle-good { 83 | color: var(--_circle, #088645); 84 | border-color: var(--_circle, #0cce6b); 85 | } 86 | .circle-ok { 87 | color: var(--_circle, #ffa400); 88 | border-color: var(--_circle, currentColor); 89 | } 90 | .circle-bad { 91 | color: var(--_circle, #ff4e42); 92 | border-color: var(--_circle, currentColor); 93 | } 94 | .meta { 95 | display: flex; 96 | align-items: center; 97 | gap: 0.625em; /* 10px /16 */ 98 | } 99 | .circle + .meta { 100 | margin-left: 0.25em; /* 4px /16 */ 101 | } 102 | .rank:before { 103 | content: "Rank #"; 104 | } 105 | .rank-change:before { 106 | line-height: 1; 107 | } 108 | .rank-change.up { 109 | color: green; 110 | } 111 | .rank-change.up:before { 112 | content: "⬆"; 113 | } 114 | .rank-change.down { 115 | color: red; 116 | } 117 | .rank-change.down:before { 118 | content: "⬇"; 119 | } 120 | `; 121 | 122 | connectedCallback() { 123 | if (!("replaceSync" in CSSStyleSheet.prototype) || this.shadowRoot) { 124 | return; 125 | } 126 | 127 | this.speedlifyUrl = this.getAttribute(SpeedlifyScore.attrs.speedlifyUrl); 128 | this.shorthash = this.getAttribute(SpeedlifyScore.attrs.hash); 129 | this.rawData = this.getAttribute(SpeedlifyScore.attrs.rawData); 130 | this.url = this.getAttribute(SpeedlifyScore.attrs.url) || window.location.href; 131 | 132 | if(!this.rawData && !this.speedlifyUrl) { 133 | console.error(`Missing \`${SpeedlifyScore.attrs.speedlifyUrl}\` attribute:`, this); 134 | return; 135 | } 136 | 137 | // async 138 | this.init(); 139 | } 140 | 141 | _initTemplate(data, forceRerender = false) { 142 | if(this.shadowRoot && !forceRerender) { 143 | return; 144 | } 145 | if(this.shadowRoot) { 146 | this.shadowRoot.innerHTML = this.render(data); 147 | return; 148 | } 149 | 150 | let shadowroot = this.attachShadow({ mode: "open" }); 151 | let sheet = new CSSStyleSheet(); 152 | sheet.replaceSync(SpeedlifyScore.css); 153 | shadowroot.adoptedStyleSheets = [sheet]; 154 | 155 | let template = document.createElement("template"); 156 | template.innerHTML = this.render(data); 157 | shadowroot.appendChild(template.content.cloneNode(true)); 158 | } 159 | 160 | async init() { 161 | if(this.rawData) { 162 | let data = JSON.parse(this.rawData); 163 | this.setDateAttributes(data); 164 | this._initTemplate(data); 165 | return; 166 | } 167 | 168 | let hash = this.shorthash; 169 | let forceRerender = false; 170 | if(!hash) { 171 | this._initTemplate(); // skeleton render 172 | forceRerender = true; 173 | 174 | // It’s much faster if you supply a `hash` attribute! 175 | hash = await urlStore.fetchHash(this.speedlifyUrl, this.url); 176 | } 177 | 178 | if(!hash) { 179 | console.error( ` could not find hash for URL (${this.url}):`, this ); 180 | return; 181 | } 182 | 183 | // Hasn’t already rendered. 184 | if(!forceRerender) { 185 | this._initTemplate(); // skeleton render 186 | forceRerender = true; 187 | } 188 | 189 | let data = await urlStore.fetchData(this.speedlifyUrl, hash); 190 | this.setDateAttributes(data); 191 | 192 | this._initTemplate(data, forceRerender); 193 | } 194 | 195 | setDateAttributes(data) { 196 | if(!("Intl" in window) || !Intl.DateTimeFormat || !data.timestamp) { 197 | return; 198 | } 199 | const date = new Intl.DateTimeFormat().format(new Date(data.timestamp)); 200 | this.setAttribute("title", `Results from ${date}`); 201 | } 202 | 203 | getScoreClass(score) { 204 | if(score === "" || score === undefined) { 205 | return "circle"; 206 | } 207 | if(score < .5) { 208 | return "circle circle-bad"; 209 | } 210 | if(score < .9) { 211 | return "circle circle-ok"; 212 | } 213 | return "circle circle-good"; 214 | } 215 | 216 | getScoreHtml(title, value = "") { 217 | return `${value ? parseInt(value * 100, 10) : "…"}`; 218 | } 219 | 220 | render(data = {}) { 221 | let attrs = SpeedlifyScore.attrs; 222 | let content = []; 223 | 224 | // no extra attributes 225 | if(!this.hasAttribute(attrs.requests) && !this.hasAttribute(attrs.weight) && !this.hasAttribute(attrs.rank) && !this.hasAttribute(attrs.rankChange) || this.hasAttribute(attrs.score)) { 226 | content.push(this.getScoreHtml("Performance", data.lighthouse?.performance)); 227 | content.push(this.getScoreHtml("Accessibility", data.lighthouse?.accessibility)); 228 | content.push(this.getScoreHtml("Best Practices", data.lighthouse?.bestPractices)); 229 | content.push(this.getScoreHtml("SEO", data.lighthouse?.seo)); 230 | } 231 | 232 | let meta = []; 233 | let summarySplit = data.weight?.summary?.split(" • ") || []; 234 | if(this.hasAttribute(attrs.requests) && summarySplit.length) { 235 | meta.push(`${summarySplit[0]}`); 236 | } 237 | if(this.hasAttribute(attrs.weight) && summarySplit.length) { 238 | meta.push(`${summarySplit[1]}`); 239 | } 240 | if(data.ranks?.cumulative) { 241 | if(this.hasAttribute(attrs.rank)) { 242 | let rankUrl = this.getAttribute("rank-url"); 243 | meta.push(`<${rankUrl ? `a href="${rankUrl}"` : "span"} class="rank">${data.ranks?.cumulative}`); 244 | } 245 | if(this.hasAttribute(attrs.rankChange) && data.previousRanks) { 246 | let change = data.previousRanks?.cumulative - data.ranks?.cumulative; 247 | meta.push(`${change !== 0 ? Math.abs(change) : ""}`); 248 | } 249 | } 250 | if(meta.length) { 251 | content.push(`${meta.join("")}`) 252 | } 253 | 254 | return content.join(""); 255 | } 256 | } 257 | 258 | if(("customElements" in window) && ("fetch" in window)) { 259 | SpeedlifyScore.register(); 260 | } --------------------------------------------------------------------------------