├── 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 |
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}${rankUrl ? "a" : "span"}>`);
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 | }
--------------------------------------------------------------------------------