From aaf33c556f549618a20954dbd4cac96dc5be058c Mon Sep 17 00:00:00 2001 From: ashharrison90 Date: Sat, 18 Nov 2023 13:04:04 +0000 Subject: [PATCH] =?UTF-8?q?Deploying=20to=20gh-pages=20from=20@=20ashharri?= =?UTF-8?q?son90/ashharrison90.github.io@152ce40aea8027dbf90dcf0afa9080c3a?= =?UTF-8?q?e4b3c37=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 404.html | 2 +- .../{j579lcJdI03CPn7JjYjnK => OCB5M0RjZMitz0CXwbl6f}/index.json | 0 .../{j579lcJdI03CPn7JjYjnK => OCB5M0RjZMitz0CXwbl6f}/posts.json | 0 .../_buildManifest.js | 2 +- .../_ssgManifest.js | 0 _next/static/chunks/pages/posts/betterer-8abc929a4abe7211.js | 1 + _next/static/chunks/pages/posts/betterer-d443851458a34c3b.js | 1 - .../chunks/pages/posts/building-this-site-06683dcf69ceefb2.js | 1 + .../chunks/pages/posts/building-this-site-c8e59ddbac4e3db3.js | 1 - .../chunks/pages/posts/bye-bye-popups-445d562948567eb2.js | 1 - .../chunks/pages/posts/bye-bye-popups-5ca2e00a15443df8.js | 1 + _next/static/chunks/pages/posts/lighthouse-406944fb944a5799.js | 1 + _next/static/chunks/pages/posts/lighthouse-e186004805166eff.js | 1 - _next/static/chunks/pages/posts/mr-robot-a5268076acb29c33.js | 1 - _next/static/chunks/pages/posts/mr-robot-fd5545a761b7692a.js | 1 + about.html | 2 +- index.html | 2 +- posts.html | 2 +- posts/betterer.html | 2 +- posts/building-this-site.html | 2 +- posts/bye-bye-popups.html | 2 +- posts/lighthouse.html | 2 +- posts/mr-robot.html | 2 +- posts/wordle-poem-1.html | 2 +- 24 files changed, 16 insertions(+), 16 deletions(-) rename _next/data/{j579lcJdI03CPn7JjYjnK => OCB5M0RjZMitz0CXwbl6f}/index.json (100%) rename _next/data/{j579lcJdI03CPn7JjYjnK => OCB5M0RjZMitz0CXwbl6f}/posts.json (100%) rename _next/static/{j579lcJdI03CPn7JjYjnK => OCB5M0RjZMitz0CXwbl6f}/_buildManifest.js (69%) rename _next/static/{j579lcJdI03CPn7JjYjnK => OCB5M0RjZMitz0CXwbl6f}/_ssgManifest.js (100%) create mode 100644 _next/static/chunks/pages/posts/betterer-8abc929a4abe7211.js delete mode 100644 _next/static/chunks/pages/posts/betterer-d443851458a34c3b.js create mode 100644 _next/static/chunks/pages/posts/building-this-site-06683dcf69ceefb2.js delete mode 100644 _next/static/chunks/pages/posts/building-this-site-c8e59ddbac4e3db3.js delete mode 100644 _next/static/chunks/pages/posts/bye-bye-popups-445d562948567eb2.js create mode 100644 _next/static/chunks/pages/posts/bye-bye-popups-5ca2e00a15443df8.js create mode 100644 _next/static/chunks/pages/posts/lighthouse-406944fb944a5799.js delete mode 100644 _next/static/chunks/pages/posts/lighthouse-e186004805166eff.js delete mode 100644 _next/static/chunks/pages/posts/mr-robot-a5268076acb29c33.js create mode 100644 _next/static/chunks/pages/posts/mr-robot-fd5545a761b7692a.js diff --git a/404.html b/404.html index a23b9aec..7d0d91fe 100644 --- a/404.html +++ b/404.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/_next/data/j579lcJdI03CPn7JjYjnK/index.json b/_next/data/OCB5M0RjZMitz0CXwbl6f/index.json similarity index 100% rename from _next/data/j579lcJdI03CPn7JjYjnK/index.json rename to _next/data/OCB5M0RjZMitz0CXwbl6f/index.json diff --git a/_next/data/j579lcJdI03CPn7JjYjnK/posts.json b/_next/data/OCB5M0RjZMitz0CXwbl6f/posts.json similarity index 100% rename from _next/data/j579lcJdI03CPn7JjYjnK/posts.json rename to _next/data/OCB5M0RjZMitz0CXwbl6f/posts.json diff --git a/_next/static/j579lcJdI03CPn7JjYjnK/_buildManifest.js b/_next/static/OCB5M0RjZMitz0CXwbl6f/_buildManifest.js similarity index 69% rename from _next/static/j579lcJdI03CPn7JjYjnK/_buildManifest.js rename to _next/static/OCB5M0RjZMitz0CXwbl6f/_buildManifest.js index 6cf59733..25723079 100644 --- a/_next/static/j579lcJdI03CPn7JjYjnK/_buildManifest.js +++ b/_next/static/OCB5M0RjZMitz0CXwbl6f/_buildManifest.js @@ -1 +1 @@ -self.__BUILD_MANIFEST=function(s,t,e,c,a,b){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,t,b,"static/css/924cf6c7f014d4bf.css","static/chunks/pages/index-6dd472063b3fc4c5.js"],"/404":["static/css/66fc42278fabd414.css","static/chunks/pages/404-2e9615c2cedf9c6c.js"],"/_error":["static/chunks/pages/_error-8353112a01355ec2.js"],"/about":[s,b,"static/css/63241db5d65670da.css","static/chunks/pages/about-f8918f8e64605f91.js"],"/posts":[s,t,"static/css/8c7e6e79f2768a10.css","static/chunks/pages/posts-7bccd1d8213fa558.js"],"/posts/betterer":[s,t,e,c,a,"static/chunks/pages/posts/betterer-d443851458a34c3b.js"],"/posts/building-this-site":[s,t,e,c,"static/css/8de932371a21f867.css","static/chunks/pages/posts/building-this-site-c8e59ddbac4e3db3.js"],"/posts/bye-bye-popups":[s,t,e,c,a,"static/chunks/pages/posts/bye-bye-popups-445d562948567eb2.js"],"/posts/lighthouse":[s,t,e,c,a,"static/chunks/pages/posts/lighthouse-e186004805166eff.js"],"/posts/mr-robot":[s,t,e,c,a,"static/chunks/pages/posts/mr-robot-a5268076acb29c33.js"],"/posts/wordle-poem-1":[s,t,e,c,"static/css/1a16fee8ac227726.css","static/chunks/pages/posts/wordle-poem-1-846feaf1de5b2492.js"],sortedPages:["/","/404","/_app","/_error","/about","/posts","/posts/betterer","/posts/building-this-site","/posts/bye-bye-popups","/posts/lighthouse","/posts/mr-robot","/posts/wordle-poem-1"]}}("static/chunks/325-1afe8d662d15208a.js","static/chunks/514-eff37331ec428f0a.js","static/chunks/381-5ebe83f9dcba520b.js","static/chunks/42-50534aaae25975b7.js","static/css/467eb3304addc271.css","static/chunks/459-1d94c8db8b3f4953.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); \ No newline at end of file +self.__BUILD_MANIFEST=function(s,t,e,c,a,o){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,t,o,"static/css/924cf6c7f014d4bf.css","static/chunks/pages/index-6dd472063b3fc4c5.js"],"/404":["static/css/66fc42278fabd414.css","static/chunks/pages/404-2e9615c2cedf9c6c.js"],"/_error":["static/chunks/pages/_error-8353112a01355ec2.js"],"/about":[s,o,"static/css/63241db5d65670da.css","static/chunks/pages/about-f8918f8e64605f91.js"],"/posts":[s,t,"static/css/8c7e6e79f2768a10.css","static/chunks/pages/posts-7bccd1d8213fa558.js"],"/posts/betterer":[s,t,e,c,a,"static/chunks/pages/posts/betterer-8abc929a4abe7211.js"],"/posts/building-this-site":[s,t,e,c,"static/css/8de932371a21f867.css","static/chunks/pages/posts/building-this-site-06683dcf69ceefb2.js"],"/posts/bye-bye-popups":[s,t,e,c,a,"static/chunks/pages/posts/bye-bye-popups-5ca2e00a15443df8.js"],"/posts/lighthouse":[s,t,e,c,a,"static/chunks/pages/posts/lighthouse-406944fb944a5799.js"],"/posts/mr-robot":[s,t,e,c,a,"static/chunks/pages/posts/mr-robot-fd5545a761b7692a.js"],"/posts/wordle-poem-1":[s,t,e,c,"static/css/1a16fee8ac227726.css","static/chunks/pages/posts/wordle-poem-1-846feaf1de5b2492.js"],sortedPages:["/","/404","/_app","/_error","/about","/posts","/posts/betterer","/posts/building-this-site","/posts/bye-bye-popups","/posts/lighthouse","/posts/mr-robot","/posts/wordle-poem-1"]}}("static/chunks/325-1afe8d662d15208a.js","static/chunks/514-eff37331ec428f0a.js","static/chunks/381-5ebe83f9dcba520b.js","static/chunks/42-50534aaae25975b7.js","static/css/467eb3304addc271.css","static/chunks/459-1d94c8db8b3f4953.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); \ No newline at end of file diff --git a/_next/static/j579lcJdI03CPn7JjYjnK/_ssgManifest.js b/_next/static/OCB5M0RjZMitz0CXwbl6f/_ssgManifest.js similarity index 100% rename from _next/static/j579lcJdI03CPn7JjYjnK/_ssgManifest.js rename to _next/static/OCB5M0RjZMitz0CXwbl6f/_ssgManifest.js diff --git a/_next/static/chunks/pages/posts/betterer-8abc929a4abe7211.js b/_next/static/chunks/pages/posts/betterer-8abc929a4abe7211.js new file mode 100644 index 00000000..65d8473a --- /dev/null +++ b/_next/static/chunks/pages/posts/betterer-8abc929a4abe7211.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[716],{7886:function(e,t,r){(window.__NEXT_P=window.__NEXT_P||[]).push(["/posts/betterer",function(){return r(9736)}])},9736:function(e,t,r){"use strict";r.r(t),r.d(t,{metadata:function(){return o}});var n=r(5893),s=r(1151),i=r(7042);let o={title:"Betterer",excerpt:"My new favourite tool when working in a large codebase.",coverImage:"/assets/blog/betterer/strict-metrics.webp",date:"2022-04-24T17:40:07.322Z",tags:["tooling","ci"]},a=e=>{let{children:t}=e;return(0,n.jsx)(i.Z,{metadata:o,children:t})};function c(e){let t=Object.assign({h2:"h2",p:"p",a:"a",pre:"pre",code:"code",strong:"strong"},(0,s.a)(),e.components);return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(t.h2,{children:"Improving incrementally"}),"\n",(0,n.jsx)(t.p,{children:"Part of the pain of working on any collaborative software project comes when trying to introduce big improvements to the codebase."}),"\n",(0,n.jsxs)(t.p,{children:["Imagine you have a large project written in TypeScript. You want to enable ",(0,n.jsx)(t.a,{href:"https://www.typescriptlang.org/tsconfig#strict",children:"strict type checking"}),", but you're a long way off fixing all the errors. How do you move forward?"]}),"\n",(0,n.jsx)(t.p,{children:"That was the situation at Grafana around 2 years ago. One option is to introduce these fixes gradually and hope you can outpace the rate at which new errors are introduced."}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("video",{autoPlay:!0,loop:!0,muted:!0,type:"video/mp4",src:"/assets/blog/betterer/escalator.mp4",alt:"Making large changes incrementally can feel like running the wrong way up an escalator."}),(0,n.jsx)("figcaption",{children:(0,n.jsx)(t.p,{children:"Making large changes incrementally can feel like running the wrong way up an\nescalator."})})]}),"\n",(0,n.jsxs)(t.p,{children:["This feels a bit like running up an escalator. Instead, we ",(0,n.jsx)(t.a,{href:"https://github.com/grafana/grafana/blob/a7afab4b8aa92c32a05057047d42bcb6a91114aa/scripts/ci-check-strict.sh",children:"introduced a shell script"})," that would run in the CI:"]}),"\n",(0,n.jsx)(t.pre,{children:(0,n.jsx)(t.code,{className:"language-bash",children:'#!/bin/bash\nset -e\n\necho -e "Collecting code stats (typescript errors & more)"\n\nERROR_COUNT_LIMIT=579\nERROR_COUNT="$(./node_modules/.bin/tsc --project tsconfig.json --noEmit --strict true | grep -oP \'Found \\K(\\d+)\')"\n\nif [ "$ERROR_COUNT" -gt $ERROR_COUNT_LIMIT ]; then\n echo -e "Typescript strict errors $ERROR_COUNT exceeded $ERROR_COUNT_LIMIT so failing build"\n exit 1\nfi\n'})}),"\n",(0,n.jsx)(t.p,{children:"There's nothing special about this. It's just a bash script that runs the typescript compiler and checks the number of strict errors against our predefined threshold, similar to a snapshot test. As errors are fixed we would update the threshold to ensure that more errors aren't introduced."}),"\n",(0,n.jsx)(t.p,{children:"The main problem was that it relied on developers remembering to update the threshold whenever they fixed errors."}),"\n",(0,n.jsxs)(t.p,{children:[(0,n.jsx)(t.strong,{children:"But"})," it did work. Over the course of 18 months we went from ~800 strict typescript errors to 0."]}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/betterer/strict-metrics.webp",alt:"TypeScript strict errors over time for the Grafana project."}),(0,n.jsx)("figcaption",{children:(0,n.jsx)(t.p,{children:"TypeScript strict errors over time for the Grafana project."})})]}),"\n",(0,n.jsx)(t.p,{children:"You can see from the graph that there are several places where the number of errors went up. This is exactly the sort of thing the script was supposed to avoid. Is there a better(er) way?"}),"\n",(0,n.jsx)(t.h2,{children:"The Betterer way"}),"\n",(0,n.jsxs)(t.p,{children:[(0,n.jsx)(t.a,{href:"https://phenomnomnominal.github.io/betterer/",children:"Betterer"})," takes this concept of snapshot testing to the next level. At it's core, it does exactly the same thing as we discussed above. ",(0,n.jsx)(t.strong,{children:"But it does all this automatically for you"}),"."]}),"\n",(0,n.jsx)(t.p,{children:"Setup is a 1-liner:"}),"\n",(0,n.jsx)(t.pre,{children:(0,n.jsx)(t.code,{className:"language-bash",children:"npx @betterer/cli init\n"})}),"\n",(0,n.jsxs)(t.p,{children:["This will install the relevant dependencies and create a ",(0,n.jsx)(t.code,{children:".betterer.ts"})," file in the project root. This ",(0,n.jsx)(t.code,{children:".betterer.ts"})," file is where you define the criteria you want to test against to improve."]}),"\n",(0,n.jsxs)(t.p,{children:["With React 18 now being released and Enzyme ",(0,n.jsx)(t.a,{href:"https://github.com/enzymejs/enzyme/issues/2429",children:"still not officially supporting React 17"}),", a good example might be ",(0,n.jsx)(t.a,{href:"https://github.com/grafana/grafana/pull/45055",children:"tracking the conversion of your unit tests from Enzyme to React Testing Library"}),". Writing a test for that couldn't be simpler:"]}),"\n",(0,n.jsx)(t.pre,{children:(0,n.jsx)(t.code,{className:"language-typescript",children:"import { regexp } from '@betterer/regexp'\n\nexport default {\n 'no enzyme tests': () => regexp(/from 'enzyme'/g).include('**/*.test.*'),\n}\n"})}),"\n",(0,n.jsxs)(t.p,{children:["This uses the ",(0,n.jsx)(t.code,{children:"@betterer/regexp"})," package to check if how many test files contain the import string ",(0,n.jsx)(t.code,{children:"from 'enzyme'"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["Betterer can then be run either as a precommit hook (",(0,n.jsx)(t.code,{children:"betterer precommit"}),") in combination with ",(0,n.jsx)(t.code,{children:"husky"}),"/",(0,n.jsx)(t.code,{children:"lint-staged"})," or in the CI (",(0,n.jsx)(t.code,{children:"betterer ci"}),") or both. Running it generates a ",(0,n.jsx)(t.code,{children:".betterer.results"})," file in the project root. This is effectively a snapshot of the current state of the codebase."]}),"\n",(0,n.jsx)(t.p,{children:"Each time Betterer runs it will compare against the previous snapshot. If a test criteria has got worse, Betterer will fail. If it gets better, Betterer will update the snapshot and tighten the restriction further. Importantly, as there's nothing that an individual developer needs to remember to do, there's no sign of any increases when we look at the graph:"}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/betterer/enzyme-metrics.webp",alt:"Number of Enzyme tests over time for the Grafana project."}),(0,n.jsx)("figcaption",{children:(0,n.jsx)(t.p,{children:"Number of Enzyme tests over time for the Grafana project."})})]}),"\n",(0,n.jsxs)(t.p,{children:["There's also support for caching to speed up running Betterer, making ",(0,n.jsx)(t.a,{href:"https://github.com/grafana/grafana/pull/45901",children:"complicated tests more viable"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["That's it! I thoroughly recommend checking the tool out and giving it a try. Huge shout out to ",(0,n.jsx)(t.a,{href:"https://twitter.com/phenomnominal",children:"@phenomnomnominal"})," for developing the package and being so responsive on ",(0,n.jsx)(t.a,{href:"https://discord.gg/YNgtXt6QVX",children:"discord"}),"."]})]})}t.default=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return(0,n.jsx)(a,Object.assign({},e,{children:(0,n.jsx)(c,e)}))}},1151:function(e,t,r){"use strict";r.d(t,{a:function(){return i}});var n=r(7294);let s=n.createContext({});function i(e){let t=n.useContext(s);return n.useMemo(function(){return"function"==typeof e?e(t):{...t,...e}},[t,e])}}},function(e){e.O(0,[325,514,381,42,774,888,179],function(){return e(e.s=7886)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/_next/static/chunks/pages/posts/betterer-d443851458a34c3b.js b/_next/static/chunks/pages/posts/betterer-d443851458a34c3b.js deleted file mode 100644 index 61546697..00000000 --- a/_next/static/chunks/pages/posts/betterer-d443851458a34c3b.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[716],{7886:function(e,t,r){(window.__NEXT_P=window.__NEXT_P||[]).push(["/posts/betterer",function(){return r(9736)}])},9736:function(e,t,r){"use strict";r.r(t),r.d(t,{metadata:function(){return o}});var n=r(5893),s=r(1151),i=r(7042);let o={title:"Betterer",excerpt:"My new favourite tool when working in a large codebase.",coverImage:"/assets/blog/betterer/strict-metrics.webp",date:"2022-04-24T17:40:07.322Z",tags:["tooling","ci"]},a=e=>{let{children:t}=e;return(0,n.jsx)(i.Z,{metadata:o,children:t})};function c(e){let t=Object.assign({h2:"h2",p:"p",a:"a",pre:"pre",code:"code",strong:"strong"},(0,s.ah)(),e.components);return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(t.h2,{children:"Improving incrementally"}),"\n",(0,n.jsx)(t.p,{children:"Part of the pain of working on any collaborative software project comes when trying to introduce big improvements to the codebase."}),"\n",(0,n.jsxs)(t.p,{children:["Imagine you have a large project written in TypeScript. You want to enable ",(0,n.jsx)(t.a,{href:"https://www.typescriptlang.org/tsconfig#strict",children:"strict type checking"}),", but you're a long way off fixing all the errors. How do you move forward?"]}),"\n",(0,n.jsx)(t.p,{children:"That was the situation at Grafana around 2 years ago. One option is to introduce these fixes gradually and hope you can outpace the rate at which new errors are introduced."}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("video",{autoPlay:!0,loop:!0,muted:!0,type:"video/mp4",src:"/assets/blog/betterer/escalator.mp4",alt:"Making large changes incrementally can feel like running the wrong way up an escalator."}),(0,n.jsx)("figcaption",{children:(0,n.jsx)(t.p,{children:"Making large changes incrementally can feel like running the wrong way up an\nescalator."})})]}),"\n",(0,n.jsxs)(t.p,{children:["This feels a bit like running up an escalator. Instead, we ",(0,n.jsx)(t.a,{href:"https://github.com/grafana/grafana/blob/a7afab4b8aa92c32a05057047d42bcb6a91114aa/scripts/ci-check-strict.sh",children:"introduced a shell script"})," that would run in the CI:"]}),"\n",(0,n.jsx)(t.pre,{children:(0,n.jsx)(t.code,{className:"language-bash",children:'#!/bin/bash\nset -e\n\necho -e "Collecting code stats (typescript errors & more)"\n\nERROR_COUNT_LIMIT=579\nERROR_COUNT="$(./node_modules/.bin/tsc --project tsconfig.json --noEmit --strict true | grep -oP \'Found \\K(\\d+)\')"\n\nif [ "$ERROR_COUNT" -gt $ERROR_COUNT_LIMIT ]; then\n echo -e "Typescript strict errors $ERROR_COUNT exceeded $ERROR_COUNT_LIMIT so failing build"\n exit 1\nfi\n'})}),"\n",(0,n.jsx)(t.p,{children:"There's nothing special about this. It's just a bash script that runs the typescript compiler and checks the number of strict errors against our predefined threshold, similar to a snapshot test. As errors are fixed we would update the threshold to ensure that more errors aren't introduced."}),"\n",(0,n.jsx)(t.p,{children:"The main problem was that it relied on developers remembering to update the threshold whenever they fixed errors."}),"\n",(0,n.jsxs)(t.p,{children:[(0,n.jsx)(t.strong,{children:"But"})," it did work. Over the course of 18 months we went from ~800 strict typescript errors to 0."]}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/betterer/strict-metrics.webp",alt:"TypeScript strict errors over time for the Grafana project."}),(0,n.jsx)("figcaption",{children:(0,n.jsx)(t.p,{children:"TypeScript strict errors over time for the Grafana project."})})]}),"\n",(0,n.jsx)(t.p,{children:"You can see from the graph that there are several places where the number of errors went up. This is exactly the sort of thing the script was supposed to avoid. Is there a better(er) way?"}),"\n",(0,n.jsx)(t.h2,{children:"The Betterer way"}),"\n",(0,n.jsxs)(t.p,{children:[(0,n.jsx)(t.a,{href:"https://phenomnomnominal.github.io/betterer/",children:"Betterer"})," takes this concept of snapshot testing to the next level. At it's core, it does exactly the same thing as we discussed above. ",(0,n.jsx)(t.strong,{children:"But it does all this automatically for you"}),"."]}),"\n",(0,n.jsx)(t.p,{children:"Setup is a 1-liner:"}),"\n",(0,n.jsx)(t.pre,{children:(0,n.jsx)(t.code,{className:"language-bash",children:"npx @betterer/cli init\n"})}),"\n",(0,n.jsxs)(t.p,{children:["This will install the relevant dependencies and create a ",(0,n.jsx)(t.code,{children:".betterer.ts"})," file in the project root. This ",(0,n.jsx)(t.code,{children:".betterer.ts"})," file is where you define the criteria you want to test against to improve."]}),"\n",(0,n.jsxs)(t.p,{children:["With React 18 now being released and Enzyme ",(0,n.jsx)(t.a,{href:"https://github.com/enzymejs/enzyme/issues/2429",children:"still not officially supporting React 17"}),", a good example might be ",(0,n.jsx)(t.a,{href:"https://github.com/grafana/grafana/pull/45055",children:"tracking the conversion of your unit tests from Enzyme to React Testing Library"}),". Writing a test for that couldn't be simpler:"]}),"\n",(0,n.jsx)(t.pre,{children:(0,n.jsx)(t.code,{className:"language-typescript",children:"import { regexp } from '@betterer/regexp'\n\nexport default {\n 'no enzyme tests': () => regexp(/from 'enzyme'/g).include('**/*.test.*'),\n}\n"})}),"\n",(0,n.jsxs)(t.p,{children:["This uses the ",(0,n.jsx)(t.code,{children:"@betterer/regexp"})," package to check if how many test files contain the import string ",(0,n.jsx)(t.code,{children:"from 'enzyme'"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["Betterer can then be run either as a precommit hook (",(0,n.jsx)(t.code,{children:"betterer precommit"}),") in combination with ",(0,n.jsx)(t.code,{children:"husky"}),"/",(0,n.jsx)(t.code,{children:"lint-staged"})," or in the CI (",(0,n.jsx)(t.code,{children:"betterer ci"}),") or both. Running it generates a ",(0,n.jsx)(t.code,{children:".betterer.results"})," file in the project root. This is effectively a snapshot of the current state of the codebase."]}),"\n",(0,n.jsx)(t.p,{children:"Each time Betterer runs it will compare against the previous snapshot. If a test criteria has got worse, Betterer will fail. If it gets better, Betterer will update the snapshot and tighten the restriction further. Importantly, as there's nothing that an individual developer needs to remember to do, there's no sign of any increases when we look at the graph:"}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/betterer/enzyme-metrics.webp",alt:"Number of Enzyme tests over time for the Grafana project."}),(0,n.jsx)("figcaption",{children:(0,n.jsx)(t.p,{children:"Number of Enzyme tests over time for the Grafana project."})})]}),"\n",(0,n.jsxs)(t.p,{children:["There's also support for caching to speed up running Betterer, making ",(0,n.jsx)(t.a,{href:"https://github.com/grafana/grafana/pull/45901",children:"complicated tests more viable"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["That's it! I thoroughly recommend checking the tool out and giving it a try. Huge shout out to ",(0,n.jsx)(t.a,{href:"https://twitter.com/phenomnominal",children:"@phenomnomnominal"})," for developing the package and being so responsive on ",(0,n.jsx)(t.a,{href:"https://discord.gg/YNgtXt6QVX",children:"discord"}),"."]})]})}t.default=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return(0,n.jsx)(a,Object.assign({},e,{children:(0,n.jsx)(c,e)}))}},1151:function(e,t,r){"use strict";r.d(t,{ah:function(){return i}});var n=r(7294);let s=n.createContext({});function i(e){let t=n.useContext(s);return n.useMemo(()=>"function"==typeof e?e(t):{...t,...e},[t,e])}}},function(e){e.O(0,[325,514,381,42,774,888,179],function(){return e(e.s=7886)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/_next/static/chunks/pages/posts/building-this-site-06683dcf69ceefb2.js b/_next/static/chunks/pages/posts/building-this-site-06683dcf69ceefb2.js new file mode 100644 index 00000000..a68a643e --- /dev/null +++ b/_next/static/chunks/pages/posts/building-this-site-06683dcf69ceefb2.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[995],{8909:function(e,t,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/posts/building-this-site",function(){return s(2478)}])},5075:function(e,t,s){"use strict";s.d(t,{Z:function(){return d}});var n=s(5893),i=s(1664),r=s.n(i),o=s(7763),a=s.n(o),l=s(9837),c=s(771),h=s.n(c);function d(e){var t,s;let{coverImage:i,excerpt:o,searchString:c,slug:d,tags:g,title:p}=e;return(0,n.jsxs)(r(),{as:"/posts/".concat(d),href:"/posts/[slug]","aria-label":p,className:h().card,"data-testid":"PostCard",children:[(0,n.jsx)("div",{"data-testid":"PostCard-image",style:{backgroundImage:"url(".concat(i,")")},className:h().backgroundImage}),(0,n.jsxs)("div",{className:h().titleContainer,children:[(0,n.jsx)("div",{className:h().title,children:(0,n.jsx)(a(),{searchWords:null!==(t=null==c?void 0:c.split(" "))&&void 0!==t?t:[],textToHighlight:p})}),(0,n.jsx)("div",{className:h().tags,children:g.map(e=>(0,n.jsx)(l.Z,{label:e,searchString:c},e))}),(0,n.jsx)("div",{className:h().excerpt,children:(0,n.jsx)(a(),{searchWords:null!==(s=null==c?void 0:c.split(" "))&&void 0!==s?s:[],textToHighlight:o})})]})]})}},2478:function(e,t,s){"use strict";s.r(t),s.d(t,{metadata:function(){return a}});var n=s(5893),i=s(1151),r=s(7042),o=s(5075);let a={title:"Building this site",excerpt:"I had a week's holiday and decided to finally build the site I've been telling myself I'll do for the last 6 years.",coverImage:"/assets/blog/building-this-site/code.webp",date:"2021-05-09T15:40:07.322Z",tags:["javascript","typescript","react","nextjs"]},l=e=>{let{children:t}=e;return(0,n.jsx)(r.Z,{metadata:a,children:t})};function c(e){let t=Object.assign({h2:"h2",p:"p",strong:"strong",ul:"ul",li:"li",ol:"ol",a:"a",em:"em",code:"code",pre:"pre"},(0,i.a)(),e.components);return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(t.h2,{children:"Why?"}),"\n",(0,n.jsx)(t.p,{children:(0,n.jsx)(t.strong,{children:"Narcissism."})}),"\n",(0,n.jsx)(t.p,{children:"Actually, the motivations for building this site are hopefully relatable."}),"\n",(0,n.jsx)(t.p,{children:"Working on the same internal copyrighted project for a few years was starting to stifle my experience with other languages, frameworks, build tools, etc. Having recently gone through a round of interviews at various companies, two issues kept cropping up: a lack of TypeScript experience and a lack of publically available code examples."}),"\n",(0,n.jsx)(t.p,{children:"So, some basic requirements to attempt to address that. This site should:"}),"\n",(0,n.jsxs)(t.ul,{children:["\n",(0,n.jsx)(t.li,{children:"be written in TypeScript"}),"\n",(0,n.jsx)(t.li,{children:"use some new bootstrap (i.e. not Create React App)"}),"\n",(0,n.jsx)(t.li,{children:"should have a mobile first design"}),"\n",(0,n.jsxs)(t.li,{children:["function as a place to demonstrate ",(0,n.jsx)(t.strong,{children:"production quality"})," code"]}),"\n"]}),"\n",(0,n.jsx)(t.p,{children:"I'll probably regret that last point."}),"\n",(0,n.jsx)(t.h2,{children:"Implementation"}),"\n",(0,n.jsx)(t.p,{children:"As with most blogs or portfolio sites, there isn't really a need for a backend server to be running to dynamically generate pages. The content lends itself to being statically generated."}),"\n",(0,n.jsx)(t.p,{children:"This has a couple of advantages:"}),"\n",(0,n.jsxs)(t.ol,{children:["\n",(0,n.jsxs)(t.li,{children:["It's ",(0,n.jsx)(t.strong,{children:"fast"}),". Being statically generated means that all we're serving up to the end user is the exact same chunk of html and javascript every time. This allows for improved caching, and faster load times as a result."]}),"\n",(0,n.jsxs)(t.li,{children:["It's ",(0,n.jsx)(t.strong,{children:"cheap"}),". Not having to run a backend server reduces costs dramatically. This site is currently hosted using ",(0,n.jsx)(t.a,{href:"https://pages.github.com/",children:"GitHub Pages"})," for free."]}),"\n"]}),"\n",(0,n.jsxs)(t.p,{children:["In terms of static site generators, there are ",(0,n.jsx)(t.a,{href:"https://jamstack.org/generators",children:"quite a few options"}),". However, after deciding to stick with React, it starts to become a bit of a two horse race between ",(0,n.jsx)(t.a,{href:"https://nextjs.org/",children:"Next.js"})," and ",(0,n.jsx)(t.a,{href:"https://www.gatsbyjs.com/",children:"Gatsby"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["I ended up choosing Next.js, primarily because of it's flexibility. Whilst Gatsby is designed purely as a static site generator, the main focus of Next.js is server side rendering. It just so happens to ",(0,n.jsx)(t.em,{children:"also"})," offer static site generation."]}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/building-this-site/people-using-next.webp",alt:"Lots of commerical companies have adopted Next.js"}),(0,n.jsx)("figcaption",{children:"Lots of commerical companies have adopted Next.js"})]}),"\n",(0,n.jsx)(t.p,{children:"So if this site ever evolves to the point where it needs a dynamic backend, it can! At the same time, I also gain experience with a framework that a lot of companies are now using. Win-win."}),"\n",(0,n.jsx)(t.h2,{children:"Design"}),"\n",(0,n.jsx)(t.p,{children:"Didn't have one."}),"\n",(0,n.jsx)(t.p,{children:"This will probably come as no surprise to anyone, but I'm not a designer. I ended up playing around with most things in the browser until I was happy. The optimal solution is probably to use wireframes up front."}),"\n",(0,n.jsx)(t.p,{children:"The only thing I'm not quite happy with is the card design for each post. But that's ok - it's good enough for now, and I'm sure it'll be tweaked in the future:"}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/building-this-site/card-design.webp",alt:"The initial card design."}),(0,n.jsx)("figcaption",{children:"The initial card design."})]}),"\n",(0,n.jsx)(t.p,{children:"Compared to the current design:"}),"\n",(0,n.jsx)(o.Z,{coverImage:a.coverImage,excerpt:a.excerpt,slug:"building-this-site",tags:a.tags,title:a.title}),"\n",(0,n.jsx)(t.h2,{children:"Colours"}),"\n",(0,n.jsxs)(t.p,{children:["One thing I wanted for the site was a theme toggle or dark mode toggle. There are ",(0,n.jsx)(t.a,{href:"https://blog.weekdone.com/why-you-should-switch-on-dark-mode/",children:"various benefits"})," to this, but I find the biggest one is that it forces you to pare back and structure your styles properly. Simplicity is key. A few different background shades, a couple of choices for text, a splash of colour for interactive elements, etc."]}),"\n",(0,n.jsxs)(t.p,{children:["What I've ended up with is a ",(0,n.jsx)(t.code,{children:"globals.scss"})," file at the root of the project that looks something like:"]}),"\n",(0,n.jsx)(t.pre,{children:(0,n.jsx)(t.code,{className:"language-scss",children:":root[data-theme='light'] {\n --background: #fff;\n --border: #e0e0e0;\n --card-brightness: 5;\n --container: #f4f4f4;\n --container-alt: #a8a8a8;\n --container--rgb: 244, 244, 244;\n --interactive: #0f9bfe;\n --hero-background-rgb: 0, 0, 0;\n --hero-text-primary: #fff;\n --icon-primary: #000;\n --icon-secondary: #525252;\n --logo-durham-primary: #002337;\n --logo-durham-secondary: #702567;\n --logo-ibm: #1f70c1;\n --logo-qinetiq: #002744;\n --text-primary: #000;\n --text-secondary: #525252;\n}\n\n:root[data-theme='dark'] {\n --background: #262626;\n --border: #525252;\n --card-brightness: 0.6;\n --container: #393939;\n --container-alt: #6f6f6f;\n --container--rgb: 57, 57, 57;\n --interactive: #60bdff;\n --hero-background-rgb: 244, 244, 244;\n --hero-text-primary: #262626;\n --icon-primary: #f4f4f4;\n --icon-secondary: #c6c6c6;\n --logo-durham-primary: #f4f4f4;\n --logo-durham-secondary: #f4f4f4;\n --logo-ibm: #1f70c1;\n --logo-qinetiq: #f4f4f4;\n --text-primary: #f4f4f4;\n --text-secondary: #c6c6c6;\n}\n"})}),"\n",(0,n.jsx)(t.p,{children:"This is nice as it makes changes to the theme extremely easy. In less than a minute I can change the site from a fairly muted modern colour palette to a kaleidoscopic nightmare."}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/building-this-site/kaleidoscopic-nightmare.webp",alt:"A kaleidoscopic nightmare."}),(0,n.jsx)("figcaption",{children:"A kaleidoscopic nightmare."})]}),"\n",(0,n.jsxs)(t.p,{children:["Originally I was intending to use a CSS-in-JS library, something like ",(0,n.jsx)(t.a,{href:"https://styled-components.com/",children:"styled components"})," or ",(0,n.jsx)(t.a,{href:"https://emotion.sh/docs/introduction",children:"Emotion"}),". But Next.js ",(0,n.jsx)(t.a,{href:"https://nextjs.org/docs/basic-features/built-in-css-support#adding-component-level-css",children:"supports css modules"})," out of the box, so I've stuck with that and been pleasantly suprised."]}),"\n",(0,n.jsx)(t.h2,{children:"Next steps"}),"\n",(0,n.jsx)(t.p,{children:"There's a few things I haven't gotten to yet."}),"\n",(0,n.jsx)(t.p,{children:"Firstly, testing. Turns out it's very easy to postpone testing when there's no real requirement and only one person working on the code. Who knew?"}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/building-this-site/no-tests.webp",alt:"Whoops!"}),(0,n.jsx)("figcaption",{children:"Whoops!"})]}),"\n",(0,n.jsxs)(t.p,{children:["Nevertheless, I'm still committed to adding tests. In the real world, good tests are one of the most important aspects of a project. I'm also going to use it as an opportunity to experiment with some more new frameworks I haven't had chance to use yet, e.g. ",(0,n.jsx)(t.a,{href:"https://playwright.dev/",children:"Playwright"})," instead of ",(0,n.jsx)(t.a,{href:"https://www.cypress.io/",children:"Cypress"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["Secondly, performance and SEO. Given how lightweight the site currently is, I don't foresee many problems. But it's just an excuse to play around with ",(0,n.jsx)(t.a,{href:"https://developers.google.com/web/tools/lighthouse",children:"Lighthouse"})," a bit more."]}),"\n",(0,n.jsxs)(t.p,{children:["And finally, grab myself a nice domain to host it on! Although that might mean ",(0,n.jsx)(t.a,{href:"https://domains.google.com/registrar/search?searchTerm=ash&tab=1",children:"a lot of searching"}),"."]}),"\n",(0,n.jsx)(t.h2,{children:"Code"}),"\n",(0,n.jsxs)(t.p,{children:["If you're curious about anything, feel free to check it all out on ",(0,n.jsx)(t.a,{href:"https://github.com/ashharrison90/ashharrison90.github.io",children:"GitHub"}),"."]})]})}t.default=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return(0,n.jsx)(l,Object.assign({},e,{children:(0,n.jsx)(c,e)}))}},771:function(e){e.exports={backgroundImage:"PostCard_backgroundImage__Fn_2R",card:"PostCard_card__Hk_bu",titleContainer:"PostCard_titleContainer__qvCw5",title:"PostCard_title__2Ad9c",excerpt:"PostCard_excerpt__GgbBT",date:"PostCard_date__9VIkR",tags:"PostCard_tags___JVz6"}},1151:function(e,t,s){"use strict";s.d(t,{a:function(){return r}});var n=s(7294);let i=n.createContext({});function r(e){let t=n.useContext(i);return n.useMemo(function(){return"function"==typeof e?e(t):{...t,...e}},[t,e])}}},function(e){e.O(0,[325,514,381,42,774,888,179],function(){return e(e.s=8909)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/_next/static/chunks/pages/posts/building-this-site-c8e59ddbac4e3db3.js b/_next/static/chunks/pages/posts/building-this-site-c8e59ddbac4e3db3.js deleted file mode 100644 index f77f23e9..00000000 --- a/_next/static/chunks/pages/posts/building-this-site-c8e59ddbac4e3db3.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[995],{8909:function(e,t,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/posts/building-this-site",function(){return s(2478)}])},5075:function(e,t,s){"use strict";s.d(t,{Z:function(){return d}});var n=s(5893),i=s(1664),r=s.n(i),o=s(7763),a=s.n(o),l=s(9837),c=s(771),h=s.n(c);function d(e){var t,s;let{coverImage:i,excerpt:o,searchString:c,slug:d,tags:g,title:p}=e;return(0,n.jsxs)(r(),{as:"/posts/".concat(d),href:"/posts/[slug]","aria-label":p,className:h().card,"data-testid":"PostCard",children:[(0,n.jsx)("div",{"data-testid":"PostCard-image",style:{backgroundImage:"url(".concat(i,")")},className:h().backgroundImage}),(0,n.jsxs)("div",{className:h().titleContainer,children:[(0,n.jsx)("div",{className:h().title,children:(0,n.jsx)(a(),{searchWords:null!==(t=null==c?void 0:c.split(" "))&&void 0!==t?t:[],textToHighlight:p})}),(0,n.jsx)("div",{className:h().tags,children:g.map(e=>(0,n.jsx)(l.Z,{label:e,searchString:c},e))}),(0,n.jsx)("div",{className:h().excerpt,children:(0,n.jsx)(a(),{searchWords:null!==(s=null==c?void 0:c.split(" "))&&void 0!==s?s:[],textToHighlight:o})})]})]})}},2478:function(e,t,s){"use strict";s.r(t),s.d(t,{metadata:function(){return a}});var n=s(5893),i=s(1151),r=s(7042),o=s(5075);let a={title:"Building this site",excerpt:"I had a week's holiday and decided to finally build the site I've been telling myself I'll do for the last 6 years.",coverImage:"/assets/blog/building-this-site/code.webp",date:"2021-05-09T15:40:07.322Z",tags:["javascript","typescript","react","nextjs"]},l=e=>{let{children:t}=e;return(0,n.jsx)(r.Z,{metadata:a,children:t})};function c(e){let t=Object.assign({h2:"h2",p:"p",strong:"strong",ul:"ul",li:"li",ol:"ol",a:"a",em:"em",code:"code",pre:"pre"},(0,i.ah)(),e.components);return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(t.h2,{children:"Why?"}),"\n",(0,n.jsx)(t.p,{children:(0,n.jsx)(t.strong,{children:"Narcissism."})}),"\n",(0,n.jsx)(t.p,{children:"Actually, the motivations for building this site are hopefully relatable."}),"\n",(0,n.jsx)(t.p,{children:"Working on the same internal copyrighted project for a few years was starting to stifle my experience with other languages, frameworks, build tools, etc. Having recently gone through a round of interviews at various companies, two issues kept cropping up: a lack of TypeScript experience and a lack of publically available code examples."}),"\n",(0,n.jsx)(t.p,{children:"So, some basic requirements to attempt to address that. This site should:"}),"\n",(0,n.jsxs)(t.ul,{children:["\n",(0,n.jsx)(t.li,{children:"be written in TypeScript"}),"\n",(0,n.jsx)(t.li,{children:"use some new bootstrap (i.e. not Create React App)"}),"\n",(0,n.jsx)(t.li,{children:"should have a mobile first design"}),"\n",(0,n.jsxs)(t.li,{children:["function as a place to demonstrate ",(0,n.jsx)(t.strong,{children:"production quality"})," code"]}),"\n"]}),"\n",(0,n.jsx)(t.p,{children:"I'll probably regret that last point."}),"\n",(0,n.jsx)(t.h2,{children:"Implementation"}),"\n",(0,n.jsx)(t.p,{children:"As with most blogs or portfolio sites, there isn't really a need for a backend server to be running to dynamically generate pages. The content lends itself to being statically generated."}),"\n",(0,n.jsx)(t.p,{children:"This has a couple of advantages:"}),"\n",(0,n.jsxs)(t.ol,{children:["\n",(0,n.jsxs)(t.li,{children:["It's ",(0,n.jsx)(t.strong,{children:"fast"}),". Being statically generated means that all we're serving up to the end user is the exact same chunk of html and javascript every time. This allows for improved caching, and faster load times as a result."]}),"\n",(0,n.jsxs)(t.li,{children:["It's ",(0,n.jsx)(t.strong,{children:"cheap"}),". Not having to run a backend server reduces costs dramatically. This site is currently hosted using ",(0,n.jsx)(t.a,{href:"https://pages.github.com/",children:"GitHub Pages"})," for free."]}),"\n"]}),"\n",(0,n.jsxs)(t.p,{children:["In terms of static site generators, there are ",(0,n.jsx)(t.a,{href:"https://jamstack.org/generators",children:"quite a few options"}),". However, after deciding to stick with React, it starts to become a bit of a two horse race between ",(0,n.jsx)(t.a,{href:"https://nextjs.org/",children:"Next.js"})," and ",(0,n.jsx)(t.a,{href:"https://www.gatsbyjs.com/",children:"Gatsby"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["I ended up choosing Next.js, primarily because of it's flexibility. Whilst Gatsby is designed purely as a static site generator, the main focus of Next.js is server side rendering. It just so happens to ",(0,n.jsx)(t.em,{children:"also"})," offer static site generation."]}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/building-this-site/people-using-next.webp",alt:"Lots of commerical companies have adopted Next.js"}),(0,n.jsx)("figcaption",{children:"Lots of commerical companies have adopted Next.js"})]}),"\n",(0,n.jsx)(t.p,{children:"So if this site ever evolves to the point where it needs a dynamic backend, it can! At the same time, I also gain experience with a framework that a lot of companies are now using. Win-win."}),"\n",(0,n.jsx)(t.h2,{children:"Design"}),"\n",(0,n.jsx)(t.p,{children:"Didn't have one."}),"\n",(0,n.jsx)(t.p,{children:"This will probably come as no surprise to anyone, but I'm not a designer. I ended up playing around with most things in the browser until I was happy. The optimal solution is probably to use wireframes up front."}),"\n",(0,n.jsx)(t.p,{children:"The only thing I'm not quite happy with is the card design for each post. But that's ok - it's good enough for now, and I'm sure it'll be tweaked in the future:"}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/building-this-site/card-design.webp",alt:"The initial card design."}),(0,n.jsx)("figcaption",{children:"The initial card design."})]}),"\n",(0,n.jsx)(t.p,{children:"Compared to the current design:"}),"\n",(0,n.jsx)(o.Z,{coverImage:a.coverImage,excerpt:a.excerpt,slug:"building-this-site",tags:a.tags,title:a.title}),"\n",(0,n.jsx)(t.h2,{children:"Colours"}),"\n",(0,n.jsxs)(t.p,{children:["One thing I wanted for the site was a theme toggle or dark mode toggle. There are ",(0,n.jsx)(t.a,{href:"https://blog.weekdone.com/why-you-should-switch-on-dark-mode/",children:"various benefits"})," to this, but I find the biggest one is that it forces you to pare back and structure your styles properly. Simplicity is key. A few different background shades, a couple of choices for text, a splash of colour for interactive elements, etc."]}),"\n",(0,n.jsxs)(t.p,{children:["What I've ended up with is a ",(0,n.jsx)(t.code,{children:"globals.scss"})," file at the root of the project that looks something like:"]}),"\n",(0,n.jsx)(t.pre,{children:(0,n.jsx)(t.code,{className:"language-scss",children:":root[data-theme='light'] {\n --background: #fff;\n --border: #e0e0e0;\n --card-brightness: 5;\n --container: #f4f4f4;\n --container-alt: #a8a8a8;\n --container--rgb: 244, 244, 244;\n --interactive: #0f9bfe;\n --hero-background-rgb: 0, 0, 0;\n --hero-text-primary: #fff;\n --icon-primary: #000;\n --icon-secondary: #525252;\n --logo-durham-primary: #002337;\n --logo-durham-secondary: #702567;\n --logo-ibm: #1f70c1;\n --logo-qinetiq: #002744;\n --text-primary: #000;\n --text-secondary: #525252;\n}\n\n:root[data-theme='dark'] {\n --background: #262626;\n --border: #525252;\n --card-brightness: 0.6;\n --container: #393939;\n --container-alt: #6f6f6f;\n --container--rgb: 57, 57, 57;\n --interactive: #60bdff;\n --hero-background-rgb: 244, 244, 244;\n --hero-text-primary: #262626;\n --icon-primary: #f4f4f4;\n --icon-secondary: #c6c6c6;\n --logo-durham-primary: #f4f4f4;\n --logo-durham-secondary: #f4f4f4;\n --logo-ibm: #1f70c1;\n --logo-qinetiq: #f4f4f4;\n --text-primary: #f4f4f4;\n --text-secondary: #c6c6c6;\n}\n"})}),"\n",(0,n.jsx)(t.p,{children:"This is nice as it makes changes to the theme extremely easy. In less than a minute I can change the site from a fairly muted modern colour palette to a kaleidoscopic nightmare."}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/building-this-site/kaleidoscopic-nightmare.webp",alt:"A kaleidoscopic nightmare."}),(0,n.jsx)("figcaption",{children:"A kaleidoscopic nightmare."})]}),"\n",(0,n.jsxs)(t.p,{children:["Originally I was intending to use a CSS-in-JS library, something like ",(0,n.jsx)(t.a,{href:"https://styled-components.com/",children:"styled components"})," or ",(0,n.jsx)(t.a,{href:"https://emotion.sh/docs/introduction",children:"Emotion"}),". But Next.js ",(0,n.jsx)(t.a,{href:"https://nextjs.org/docs/basic-features/built-in-css-support#adding-component-level-css",children:"supports css modules"})," out of the box, so I've stuck with that and been pleasantly suprised."]}),"\n",(0,n.jsx)(t.h2,{children:"Next steps"}),"\n",(0,n.jsx)(t.p,{children:"There's a few things I haven't gotten to yet."}),"\n",(0,n.jsx)(t.p,{children:"Firstly, testing. Turns out it's very easy to postpone testing when there's no real requirement and only one person working on the code. Who knew?"}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/building-this-site/no-tests.webp",alt:"Whoops!"}),(0,n.jsx)("figcaption",{children:"Whoops!"})]}),"\n",(0,n.jsxs)(t.p,{children:["Nevertheless, I'm still committed to adding tests. In the real world, good tests are one of the most important aspects of a project. I'm also going to use it as an opportunity to experiment with some more new frameworks I haven't had chance to use yet, e.g. ",(0,n.jsx)(t.a,{href:"https://playwright.dev/",children:"Playwright"})," instead of ",(0,n.jsx)(t.a,{href:"https://www.cypress.io/",children:"Cypress"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["Secondly, performance and SEO. Given how lightweight the site currently is, I don't foresee many problems. But it's just an excuse to play around with ",(0,n.jsx)(t.a,{href:"https://developers.google.com/web/tools/lighthouse",children:"Lighthouse"})," a bit more."]}),"\n",(0,n.jsxs)(t.p,{children:["And finally, grab myself a nice domain to host it on! Although that might mean ",(0,n.jsx)(t.a,{href:"https://domains.google.com/registrar/search?searchTerm=ash&tab=1",children:"a lot of searching"}),"."]}),"\n",(0,n.jsx)(t.h2,{children:"Code"}),"\n",(0,n.jsxs)(t.p,{children:["If you're curious about anything, feel free to check it all out on ",(0,n.jsx)(t.a,{href:"https://github.com/ashharrison90/ashharrison90.github.io",children:"GitHub"}),"."]})]})}t.default=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return(0,n.jsx)(l,Object.assign({},e,{children:(0,n.jsx)(c,e)}))}},771:function(e){e.exports={backgroundImage:"PostCard_backgroundImage__Fn_2R",card:"PostCard_card__Hk_bu",titleContainer:"PostCard_titleContainer__qvCw5",title:"PostCard_title__2Ad9c",excerpt:"PostCard_excerpt__GgbBT",date:"PostCard_date__9VIkR",tags:"PostCard_tags___JVz6"}},1151:function(e,t,s){"use strict";s.d(t,{ah:function(){return r}});var n=s(7294);let i=n.createContext({});function r(e){let t=n.useContext(i);return n.useMemo(()=>"function"==typeof e?e(t):{...t,...e},[t,e])}}},function(e){e.O(0,[325,514,381,42,774,888,179],function(){return e(e.s=8909)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/_next/static/chunks/pages/posts/bye-bye-popups-445d562948567eb2.js b/_next/static/chunks/pages/posts/bye-bye-popups-445d562948567eb2.js deleted file mode 100644 index 7610d65e..00000000 --- a/_next/static/chunks/pages/posts/bye-bye-popups-445d562948567eb2.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[7],{8896:function(e,t,n){(window.__NEXT_P=window.__NEXT_P||[]).push(["/posts/bye-bye-popups",function(){return n(6225)}])},6225:function(e,t,n){"use strict";n.r(t),n.d(t,{metadata:function(){return i}});var o=n(5893),s=n(1151),a=n(7042);let i={title:"Bye bye popups",excerpt:"For about 5 hours, our custom popups completely disappeared. Here's how Google ruined my day.",coverImage:"/assets/blog/bye-bye-popups/popups-demo-page.webp",date:"2021-06-11T17:00:07.322Z",tags:["javascript","angularjs","cypress","chrome"]},r=e=>{let{children:t}=e;return(0,o.jsx)(a.Z,{metadata:i,children:t})};function p(e){let t=Object.assign({h2:"h2",p:"p",a:"a",pre:"pre",code:"code",ul:"ul",li:"li"},(0,s.ah)(),e.components);return(0,o.jsxs)(o.Fragment,{children:[(0,o.jsx)(t.h2,{children:"End to end tests are important"}),"\n",(0,o.jsxs)(t.p,{children:["At the time, we used ",(0,o.jsx)(t.a,{href:"https://www.cypress.io/",children:"Cypress"})," for our end-to-end tests. We have a test that runs locally that looks something like:"]}),"\n",(0,o.jsx)(t.pre,{children:(0,o.jsx)(t.code,{className:"language-javascript",children:"describe('sticky header and breadcrumbs', () => {\n beforeEach(() => {\n cy.visit('/#/popup-next')\n cy.get('.expression-editor-popup-next').eq(0).as('popup')\n })\n\n it('has sticky header when source is expanded (not scrolled yet)', () => {\n cy.get('@popup').within(() => {\n cy.get('.expression-editor-popup-next__entity--stuck').within(() => {\n cy.get('.expression-editor-popup-next__source-heading-title').should(\n 'have.attr',\n 'title',\n 'Workday / Operation / Operation',\n )\n const titleParts = cy.get(\n '.expression-editor-popup-next__source-heading-title-part',\n )\n titleParts.should('have.length', 3)\n titleParts.eq(0).should('have.text', 'Workday')\n titleParts.eq(1).should('have.text', 'Operation')\n titleParts.eq(2).should('have.text', 'Operation')\n })\n })\n })\n})\n"})}),"\n",(0,o.jsx)(t.p,{children:"The actual behaviour being tested here isn't particularly important. The gist of it is that we're navigating to the popup demo page, finding the first popup and testing that the sticky header is displaying the correct information."}),"\n",(0,o.jsx)(t.p,{children:"Our automated build runs and the test fails. One nice thing about Cypress is it can output videos or screenshots of your tests runs at the point of failure. So I go to the build system to take a look, and..."}),"\n",(0,o.jsxs)("figure",{children:[(0,o.jsx)("img",{src:"/assets/blog/bye-bye-popups/missing-popups.webp",alt:"What happened to our popups?!"}),(0,o.jsx)("figcaption",{children:"What happened to our popups?!"})]}),"\n",(0,o.jsx)(t.p,{children:"They're all gone! Ok. We can work with that. At least it makes sense why the test is failing. Now to recreate locally."}),"\n",(0,o.jsx)(t.p,{children:"...and of course it passes."}),"\n",(0,o.jsx)(t.p,{children:"At this point, we're not too concerned. Everyone that's worked in software knows these things happen every day. A package updates and breaks some of your behaviour. No big deal, that's what the tests are for. And until the tests are passing nothing will get pushed to production anyway."}),"\n",(0,o.jsx)(t.p,{children:"Eventually (like 2 hours later) we track it down to a newer version of Chrome (90) being installed on the CI. Updating Chrome locally now shows the behaviour as well. Unfortunately, it's not just limited to tests or our demo environment. It's happening everywhere."}),"\n",(0,o.jsx)(t.p,{children:"Including production."}),"\n",(0,o.jsx)(t.h2,{children:"Some history"}),"\n",(0,o.jsxs)(t.p,{children:["The product I was working on at the time began life as an AngularJS application. This is a particularly common story for most products written 5 or 6 years ago. AngularJS was a well-established framework, and React's terms of use ",(0,o.jsx)(t.a,{href:"https://medium.com/bits-and-pixels/a-compelling-reason-not-to-use-reactjs-beac24402f7b",children:"left a lot to be desired"}),"."]}),"\n",(0,o.jsxs)(t.p,{children:["Fast forward a couple of years and the growth of React, coupled with the impending ",(0,o.jsx)(t.a,{href:"https://blog.angular.io/stable-angularjs-and-long-term-support-7e077635ee9c",children:"end of AngularJS's LTS"}),", meant a migration plan was needed. Enter ",(0,o.jsx)(t.a,{href:"https://www.npmjs.com/package/react2angular",children:(0,o.jsx)(t.code,{children:"react2angular"})}),". This allowed for a gradual transition of the components to React implementations."]}),"\n",(0,o.jsx)(t.p,{children:"The process looked something like:"}),"\n",(0,o.jsxs)(t.ul,{children:["\n",(0,o.jsx)(t.li,{children:"pick a component with no dependencies on other AngularJS components"}),"\n",(0,o.jsx)(t.li,{children:"create a new like-for-like React implementation"}),"\n",(0,o.jsx)(t.li,{children:"replace the definition of the AngularJS component with the new React implementation"}),"\n",(0,o.jsx)(t.li,{children:"move up the component tree and repeat"}),"\n"]}),"\n",(0,o.jsx)(t.p,{children:"Eventually, you reach the top of the component tree and can convert the whole application. In places that haven't been completely converted yet, there might be some code that looks like:"}),"\n",(0,o.jsx)(t.pre,{children:(0,o.jsx)(t.code,{className:"language-javascript",children:"import Popup from '../react/popup/popup'\nexport default angular.module('uimapper')\n ...\n .component('popup', react2angular(Popup))\n"})}),"\n",(0,o.jsxs)(t.p,{children:["This shows the definition an AngularJS component, ",(0,o.jsx)(t.code,{children:"popup"}),", using ",(0,o.jsx)(t.code,{children:"react2angular"})," to alias that component to the newer React implementation."]}),"\n",(0,o.jsx)(t.p,{children:"So why does any of this matter?"}),"\n",(0,o.jsx)(t.h2,{children:"Naming conventions are more important"}),"\n",(0,o.jsxs)(t.p,{children:["Well, for anyone that's unfamiliar with AngularJS, this will then be inserted into the DOM as ",(0,o.jsx)(t.code,{children:""}),"."]}),"\n",(0,o.jsxs)(t.p,{children:["Tucked away in the changes for Chrome 90 was ",(0,o.jsx)(t.a,{href:"https://chromium.googlesource.com/chromium/src/+/2024c426de3346666cb45f9c65ad9dec2246be99",children:"this commit."})," After some googling, it turns out ",(0,o.jsxs)(t.a,{href:"https://www.chromestatus.com/feature/5463833265045504",children:["Chrome is starting to implement a native ",(0,o.jsx)(t.code,{children:""})," element."]})," In doing so, they've added some new styles to the Chrome style sheet to handle the display of this element."]}),"\n",(0,o.jsxs)("figure",{children:[(0,o.jsx)("img",{src:"/assets/blog/bye-bye-popups/popup-style.webp",alt:"The offending style."}),(0,o.jsx)("figcaption",{children:"Some helpful new styles."})]}),"\n",(0,o.jsx)(t.p,{children:"At this point, cogs are finally starting to turn. Remember our component from earlier? What did we call it?"}),"\n",(0,o.jsx)(t.p,{children:(0,o.jsx)(t.code,{children:"popup"})}),"\n",(0,o.jsx)(t.p,{children:"We're fucking idiots. A short rename later, and we're back in business:"}),"\n",(0,o.jsxs)("figure",{children:[(0,o.jsx)("img",{src:"/assets/blog/bye-bye-popups/popups-demo-page.webp",alt:"What the demo page should look like."}),(0,o.jsx)("figcaption",{children:"What the demo page should look like."})]}),"\n",(0,o.jsx)(t.h2,{children:"Takeaways"}),"\n",(0,o.jsxs)(t.p,{children:["There's now a ",(0,o.jsx)(t.a,{href:"https://support.google.com/chrome/thread/106244569/chrome-90-hides-my-websites-popup-dialogs-interial-popup-open?hl=en",children:"helpful support issue"})," discussing the problem, where it's pointed out that the ",(0,o.jsxs)(t.a,{href:"https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name",children:["HTML spec requires that custom elements include a ",(0,o.jsx)(t.code,{children:"-"}),"."]})," And, of course, they're right. Never create custom elements without including a ",(0,o.jsx)(t.code,{children:"-"})," in the name!"]}),"\n",(0,o.jsx)(t.p,{children:"That didn't make it any less annoying to figure out at the time."})]})}t.default=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return(0,o.jsx)(r,Object.assign({},e,{children:(0,o.jsx)(p,e)}))}},1151:function(e,t,n){"use strict";n.d(t,{ah:function(){return a}});var o=n(7294);let s=o.createContext({});function a(e){let t=o.useContext(s);return o.useMemo(()=>"function"==typeof e?e(t):{...t,...e},[t,e])}}},function(e){e.O(0,[325,514,381,42,774,888,179],function(){return e(e.s=8896)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/_next/static/chunks/pages/posts/bye-bye-popups-5ca2e00a15443df8.js b/_next/static/chunks/pages/posts/bye-bye-popups-5ca2e00a15443df8.js new file mode 100644 index 00000000..33eb8ae1 --- /dev/null +++ b/_next/static/chunks/pages/posts/bye-bye-popups-5ca2e00a15443df8.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[7],{8896:function(e,t,n){(window.__NEXT_P=window.__NEXT_P||[]).push(["/posts/bye-bye-popups",function(){return n(6225)}])},6225:function(e,t,n){"use strict";n.r(t),n.d(t,{metadata:function(){return i}});var o=n(5893),s=n(1151),a=n(7042);let i={title:"Bye bye popups",excerpt:"For about 5 hours, our custom popups completely disappeared. Here's how Google ruined my day.",coverImage:"/assets/blog/bye-bye-popups/popups-demo-page.webp",date:"2021-06-11T17:00:07.322Z",tags:["javascript","angularjs","cypress","chrome"]},r=e=>{let{children:t}=e;return(0,o.jsx)(a.Z,{metadata:i,children:t})};function p(e){let t=Object.assign({h2:"h2",p:"p",a:"a",pre:"pre",code:"code",ul:"ul",li:"li"},(0,s.a)(),e.components);return(0,o.jsxs)(o.Fragment,{children:[(0,o.jsx)(t.h2,{children:"End to end tests are important"}),"\n",(0,o.jsxs)(t.p,{children:["At the time, we used ",(0,o.jsx)(t.a,{href:"https://www.cypress.io/",children:"Cypress"})," for our end-to-end tests. We have a test that runs locally that looks something like:"]}),"\n",(0,o.jsx)(t.pre,{children:(0,o.jsx)(t.code,{className:"language-javascript",children:"describe('sticky header and breadcrumbs', () => {\n beforeEach(() => {\n cy.visit('/#/popup-next')\n cy.get('.expression-editor-popup-next').eq(0).as('popup')\n })\n\n it('has sticky header when source is expanded (not scrolled yet)', () => {\n cy.get('@popup').within(() => {\n cy.get('.expression-editor-popup-next__entity--stuck').within(() => {\n cy.get('.expression-editor-popup-next__source-heading-title').should(\n 'have.attr',\n 'title',\n 'Workday / Operation / Operation',\n )\n const titleParts = cy.get(\n '.expression-editor-popup-next__source-heading-title-part',\n )\n titleParts.should('have.length', 3)\n titleParts.eq(0).should('have.text', 'Workday')\n titleParts.eq(1).should('have.text', 'Operation')\n titleParts.eq(2).should('have.text', 'Operation')\n })\n })\n })\n})\n"})}),"\n",(0,o.jsx)(t.p,{children:"The actual behaviour being tested here isn't particularly important. The gist of it is that we're navigating to the popup demo page, finding the first popup and testing that the sticky header is displaying the correct information."}),"\n",(0,o.jsx)(t.p,{children:"Our automated build runs and the test fails. One nice thing about Cypress is it can output videos or screenshots of your tests runs at the point of failure. So I go to the build system to take a look, and..."}),"\n",(0,o.jsxs)("figure",{children:[(0,o.jsx)("img",{src:"/assets/blog/bye-bye-popups/missing-popups.webp",alt:"What happened to our popups?!"}),(0,o.jsx)("figcaption",{children:"What happened to our popups?!"})]}),"\n",(0,o.jsx)(t.p,{children:"They're all gone! Ok. We can work with that. At least it makes sense why the test is failing. Now to recreate locally."}),"\n",(0,o.jsx)(t.p,{children:"...and of course it passes."}),"\n",(0,o.jsx)(t.p,{children:"At this point, we're not too concerned. Everyone that's worked in software knows these things happen every day. A package updates and breaks some of your behaviour. No big deal, that's what the tests are for. And until the tests are passing nothing will get pushed to production anyway."}),"\n",(0,o.jsx)(t.p,{children:"Eventually (like 2 hours later) we track it down to a newer version of Chrome (90) being installed on the CI. Updating Chrome locally now shows the behaviour as well. Unfortunately, it's not just limited to tests or our demo environment. It's happening everywhere."}),"\n",(0,o.jsx)(t.p,{children:"Including production."}),"\n",(0,o.jsx)(t.h2,{children:"Some history"}),"\n",(0,o.jsxs)(t.p,{children:["The product I was working on at the time began life as an AngularJS application. This is a particularly common story for most products written 5 or 6 years ago. AngularJS was a well-established framework, and React's terms of use ",(0,o.jsx)(t.a,{href:"https://medium.com/bits-and-pixels/a-compelling-reason-not-to-use-reactjs-beac24402f7b",children:"left a lot to be desired"}),"."]}),"\n",(0,o.jsxs)(t.p,{children:["Fast forward a couple of years and the growth of React, coupled with the impending ",(0,o.jsx)(t.a,{href:"https://blog.angular.io/stable-angularjs-and-long-term-support-7e077635ee9c",children:"end of AngularJS's LTS"}),", meant a migration plan was needed. Enter ",(0,o.jsx)(t.a,{href:"https://www.npmjs.com/package/react2angular",children:(0,o.jsx)(t.code,{children:"react2angular"})}),". This allowed for a gradual transition of the components to React implementations."]}),"\n",(0,o.jsx)(t.p,{children:"The process looked something like:"}),"\n",(0,o.jsxs)(t.ul,{children:["\n",(0,o.jsx)(t.li,{children:"pick a component with no dependencies on other AngularJS components"}),"\n",(0,o.jsx)(t.li,{children:"create a new like-for-like React implementation"}),"\n",(0,o.jsx)(t.li,{children:"replace the definition of the AngularJS component with the new React implementation"}),"\n",(0,o.jsx)(t.li,{children:"move up the component tree and repeat"}),"\n"]}),"\n",(0,o.jsx)(t.p,{children:"Eventually, you reach the top of the component tree and can convert the whole application. In places that haven't been completely converted yet, there might be some code that looks like:"}),"\n",(0,o.jsx)(t.pre,{children:(0,o.jsx)(t.code,{className:"language-javascript",children:"import Popup from '../react/popup/popup'\nexport default angular.module('uimapper')\n ...\n .component('popup', react2angular(Popup))\n"})}),"\n",(0,o.jsxs)(t.p,{children:["This shows the definition an AngularJS component, ",(0,o.jsx)(t.code,{children:"popup"}),", using ",(0,o.jsx)(t.code,{children:"react2angular"})," to alias that component to the newer React implementation."]}),"\n",(0,o.jsx)(t.p,{children:"So why does any of this matter?"}),"\n",(0,o.jsx)(t.h2,{children:"Naming conventions are more important"}),"\n",(0,o.jsxs)(t.p,{children:["Well, for anyone that's unfamiliar with AngularJS, this will then be inserted into the DOM as ",(0,o.jsx)(t.code,{children:""}),"."]}),"\n",(0,o.jsxs)(t.p,{children:["Tucked away in the changes for Chrome 90 was ",(0,o.jsx)(t.a,{href:"https://chromium.googlesource.com/chromium/src/+/2024c426de3346666cb45f9c65ad9dec2246be99",children:"this commit."})," After some googling, it turns out ",(0,o.jsxs)(t.a,{href:"https://www.chromestatus.com/feature/5463833265045504",children:["Chrome is starting to implement a native ",(0,o.jsx)(t.code,{children:""})," element."]})," In doing so, they've added some new styles to the Chrome style sheet to handle the display of this element."]}),"\n",(0,o.jsxs)("figure",{children:[(0,o.jsx)("img",{src:"/assets/blog/bye-bye-popups/popup-style.webp",alt:"The offending style."}),(0,o.jsx)("figcaption",{children:"Some helpful new styles."})]}),"\n",(0,o.jsx)(t.p,{children:"At this point, cogs are finally starting to turn. Remember our component from earlier? What did we call it?"}),"\n",(0,o.jsx)(t.p,{children:(0,o.jsx)(t.code,{children:"popup"})}),"\n",(0,o.jsx)(t.p,{children:"We're fucking idiots. A short rename later, and we're back in business:"}),"\n",(0,o.jsxs)("figure",{children:[(0,o.jsx)("img",{src:"/assets/blog/bye-bye-popups/popups-demo-page.webp",alt:"What the demo page should look like."}),(0,o.jsx)("figcaption",{children:"What the demo page should look like."})]}),"\n",(0,o.jsx)(t.h2,{children:"Takeaways"}),"\n",(0,o.jsxs)(t.p,{children:["There's now a ",(0,o.jsx)(t.a,{href:"https://support.google.com/chrome/thread/106244569/chrome-90-hides-my-websites-popup-dialogs-interial-popup-open?hl=en",children:"helpful support issue"})," discussing the problem, where it's pointed out that the ",(0,o.jsxs)(t.a,{href:"https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name",children:["HTML spec requires that custom elements include a ",(0,o.jsx)(t.code,{children:"-"}),"."]})," And, of course, they're right. Never create custom elements without including a ",(0,o.jsx)(t.code,{children:"-"})," in the name!"]}),"\n",(0,o.jsx)(t.p,{children:"That didn't make it any less annoying to figure out at the time."})]})}t.default=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return(0,o.jsx)(r,Object.assign({},e,{children:(0,o.jsx)(p,e)}))}},1151:function(e,t,n){"use strict";n.d(t,{a:function(){return a}});var o=n(7294);let s=o.createContext({});function a(e){let t=o.useContext(s);return o.useMemo(function(){return"function"==typeof e?e(t):{...t,...e}},[t,e])}}},function(e){e.O(0,[325,514,381,42,774,888,179],function(){return e(e.s=8896)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/_next/static/chunks/pages/posts/lighthouse-406944fb944a5799.js b/_next/static/chunks/pages/posts/lighthouse-406944fb944a5799.js new file mode 100644 index 00000000..76c605bf --- /dev/null +++ b/_next/static/chunks/pages/posts/lighthouse-406944fb944a5799.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[849],{3427:function(e,t,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/posts/lighthouse",function(){return s(7794)}])},7794:function(e,t,s){"use strict";s.r(t),s.d(t,{metadata:function(){return r}});var n=s(5893),i=s(1151),o=s(7042);let r={title:"Lighthouse",excerpt:"How to improve your site for everyone using Lighthouse CI reports.",coverImage:"/assets/blog/lighthouse/lighthouse.webp",date:"2021-07-11T17:40:07.322Z",tags:["accessibility","seo","performance"]},h=e=>{let{children:t}=e;return(0,n.jsx)(o.Z,{metadata:r,children:t})};function l(e){let t=Object.assign({h2:"h2",blockquote:"blockquote",p:"p",a:"a",strong:"strong",code:"code",em:"em",ul:"ul",li:"li",pre:"pre"},(0,i.a)(),e.components);return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(t.h2,{children:"What is Lighthouse?"}),"\n",(0,n.jsxs)(t.blockquote,{children:["\n",(0,n.jsx)(t.p,{children:"Lighthouse analyzes web apps and web pages, collecting modern performance metrics and insights on developer best practices."}),"\n"]}),"\n",(0,n.jsxs)(t.p,{children:["This is the description over on ",(0,n.jsx)(t.a,{href:"https://github.com/GoogleChrome/lighthouse#readme",children:"their README"}),". Unpicking that a bit; Lighthouse is a tool developed by Google which monitors a page as it renders and calculates scores for various categories such as ",(0,n.jsx)(t.strong,{children:"performance"}),", ",(0,n.jsx)(t.strong,{children:"accessibility"}),", and ",(0,n.jsx)(t.strong,{children:"SEO"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["If you're reading this in Chrome, you can run Lighthouse right now in the browser. Right click -> ",(0,n.jsx)(t.code,{children:"Inspect"})," anywhere on the page to open the developer tools, and open the ",(0,n.jsx)(t.code,{children:"Lighthouse"})," tab."]}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/lighthouse/chrome-dev-tools.webp",alt:"How to find Lighthouse in the Chrome dev tools."}),(0,n.jsx)("figcaption",{children:"How to find Lighthouse in the Chrome dev tools."})]}),"\n",(0,n.jsx)(t.p,{children:"Here you'll be able to generate a Lighthouse report against any site you like."}),"\n",(0,n.jsxs)(t.p,{children:["How the scores are calculated and weighted could be the topic of a whole article in itself. In short, scores are out of 100 and higher is better. If you're interested, you can read more about how the performance score is calculated ",(0,n.jsx)(t.a,{href:"https://web.dev/performance-scoring/",children:"here"}),". They also include ",(0,n.jsx)(t.a,{href:"https://googlechrome.github.io/lighthouse/scorecalc/",children:"this handy calculator"})," to see how improvements to various metrics can affect your overall score."]}),"\n",(0,n.jsx)(t.h2,{children:"So what?"}),"\n",(0,n.jsx)(t.p,{children:"So why should we care? Well, these metrics are intended to evaluate how well your site performs for everyone."}),"\n",(0,n.jsxs)(t.p,{children:["Let's use an example. If your site isn't properly accessible, you could be excluding the ",(0,n.jsx)(t.a,{href:"https://nfb.org/resources/blindness-statistics",children:"2.4% of your audience with visual disabilities"}),". And when your potential audience is the entire online population, that is ",(0,n.jsx)("del",{children:"a metric fuckton"})," ",(0,n.jsx)("ins",{children:"nearly 112 million people"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["If that's not enough, here's another important factor: Google performs similar audits and ",(0,n.jsx)(t.a,{href:"https://www.google.com/intl/en_uk/search/howsearchworks/algorithms/",children:"uses this information when deciding how to rank search results"}),". If you want your site to be at the top of Google, focus on making it useful, performant and accessible for everyone first."]}),"\n",(0,n.jsx)(t.h2,{children:"Running an initial scan"}),"\n",(0,n.jsxs)(t.p,{children:["So this all ",(0,n.jsx)(t.em,{children:"sounds"})," great, but there's no way I'm relying on a manual process of running a scan and analysing the results each time. Well, Google provides some tools to run Lighthouse programmatically: ",(0,n.jsx)(t.a,{href:"https://github.com/GoogleChrome/lighthouse-ci",children:"Lighthouse CI"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["To get started, I followed ",(0,n.jsx)(t.a,{href:"https://web.dev/lighthouse-ci/",children:"this excellent tutorial"})," over on web.dev. This allowed me to set up:"]}),"\n",(0,n.jsxs)(t.ul,{children:["\n",(0,n.jsx)(t.li,{children:"An npm script to run Lighthouse against my site on my machine."}),"\n",(0,n.jsx)(t.li,{children:"A GitHub action to run Lighthouse as part of the existing testing on commits/pull requests."}),"\n",(0,n.jsx)(t.li,{children:"Automatic pushing of the final report to temporary public storage"}),"\n"]}),"\n",(0,n.jsx)(t.p,{children:"And within half an hour or so, I had my first automated result:"}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/lighthouse/first-run.webp",alt:"First Lighthouse run results"}),(0,n.jsx)("figcaption",{children:"Results of the first Lighthouse scan."})]}),"\n",(0,n.jsx)(t.p,{children:"Not bad. But I couldn't help but be slightly disappointed with such a low performance score given the lightweight nature of the site. We'll come back to that later..."}),"\n",(0,n.jsx)(t.h2,{children:"Fixing!"}),"\n",(0,n.jsx)(t.p,{children:"Step 1 - tackle the low hanging fruit:"}),"\n",(0,n.jsxs)(t.ul,{children:["\n",(0,n.jsx)(t.li,{children:(0,n.jsxs)(t.a,{href:"https://web.dev/html-has-lang/",children:[(0,n.jsx)(t.code,{children:""})," element does not have a ",(0,n.jsx)(t.code,{children:"[lang]"})," attribute"]})}),"\n",(0,n.jsx)(t.li,{children:(0,n.jsx)(t.a,{href:"https://web.dev/external-anchors-use-rel-noopener/",children:"Links to cross-origin destinations are unsafe"})}),"\n",(0,n.jsx)(t.li,{children:(0,n.jsx)(t.a,{href:"https://web.dev/link-text/",children:"Links do not have descriptive text"})}),"\n",(0,n.jsx)(t.li,{children:(0,n.jsx)(t.a,{href:"https://web.dev/uses-webp-images/",children:"Serve images in modern formats"})}),"\n"]}),"\n",(0,n.jsxs)(t.p,{children:["There's not too much to say about these. I was most surprised by the savings gained from switching to use ",(0,n.jsx)(t.code,{children:".webp"})," images instead of ",(0,n.jsx)(t.code,{children:".png"}),". ",(0,n.jsx)(t.code,{children:".webp"})," is now ",(0,n.jsx)(t.a,{href:"https://caniuse.com/webp",children:"supported in all major browsers"})," (friendly reminder that IE is not major anymore...) and ",(0,n.jsx)(t.a,{href:"https://insanelab.com/blog/web-development/webp-web-design-vs-jpeg-gif-png/",children:"saves around 26% of the file size"}),". I can't really find a downside other than the slight annoyance of having to run any screen captures through a conversion tool."]}),"\n",(0,n.jsx)(t.p,{children:"Step 2 - spend ages trying to fix the last remaining performance problem only to eventually give up."}),"\n",(0,n.jsxs)(t.p,{children:["Remember our low performance score from earlier? It's actually being caused by an extremely high ",(0,n.jsx)(t.a,{href:"https://web.dev/lcp/",children:"largest contentful paint"})," time of 7.6s. Largest contentful paint is supposed to measure the time at which the largest element containing content renders. This should include images. However, for some reason, on my site, it's not."]}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/lighthouse/largest-contentful-paint.webp",alt:"The largest contentful paint element."}),(0,n.jsx)("figcaption",{children:"No. Bad Lighthouse."})]}),"\n",(0,n.jsxs)(t.p,{children:["Instead it thinks the heading is the LCP element. So it waits whilst it painstakingly types out all the characters of ",(0,n.jsx)(t.code,{children:"hi i'm ash"}),". Replacing this with plain text gives me a performance score of 100. That's also a bit of a false result, because it still thinks the LCP element is the heading."]}),"\n",(0,n.jsx)(t.p,{children:"I will eventually figure out how to make it recognise the images. I suspect it's something to do with the parallax container they're housed in, or the fact they're positioned absolutely. But for now, I've given up."}),"\n",(0,n.jsx)(t.p,{children:"And so, our final results:"}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/lighthouse/final-results.webp",alt:"Final Lighthouse run results."}),(0,n.jsx)("figcaption",{children:"The final result. For now."})]}),"\n",(0,n.jsxs)(t.p,{children:["You may notice the performance score has actually gone down even more. That's because I ",(0,n.jsxs)(t.a,{href:"https://github.com/ashharrison90/ashharrison90.github.io/commit/3d6b0b7b44f652fb25f7b7c772d55ddf1f52a359",children:["switched to using a library called ",(0,n.jsx)(t.code,{children:"typewriter-effect"})," to achieve the typewriter animation on the home page"]}),". It intentionally completes the animation slightly slower than before, which causes Lighthouse to return a lower score."]}),"\n",(0,n.jsx)(t.h2,{children:"Preventing regressions with presets"}),"\n",(0,n.jsxs)(t.p,{children:["Over in the README for Lighthouse CI there's a section on ",(0,n.jsx)(t.a,{href:"https://github.com/GoogleChrome/lighthouse-ci/blob/main/docs/configuration.md#next-level",children:"next steps"}),". Here it advocates using the ",(0,n.jsx)(t.code,{children:"lighthouse:recommended"})," preset. This sets up a bunch of assertions on the collected metrics and fails the run if they dip below a certain threshold."]}),"\n",(0,n.jsx)(t.p,{children:"There's 2 things to note:"}),"\n",(0,n.jsxs)(t.ul,{children:["\n",(0,n.jsx)(t.li,{children:"You can disable individual rules with something like this in your config file:"}),"\n"]}),"\n",(0,n.jsx)(t.pre,{children:(0,n.jsx)(t.code,{className:"language-json",children:'{\n "ci": {\n "assert": {\n "preset": "lighthouse:recommended",\n "assertions": {\n "uses-webp-images": "off"\n }\n }\n ...\n }\n}\n'})}),"\n",(0,n.jsxs)(t.ul,{children:["\n",(0,n.jsxs)(t.li,{children:["I would actually recommend using the ",(0,n.jsx)(t.code,{children:"lighthouse:no-pwa"})," preset instead. This has all the recommended settings, but without the progressive web app section which can be ",(0,n.jsx)(t.a,{href:"https://github.com/GoogleChrome/lighthouse-ci/issues/51",children:"a little strict"}),"."]}),"\n"]}),"\n",(0,n.jsx)(t.h2,{children:"Final thoughts"}),"\n",(0,n.jsx)(t.p,{children:"Lighthouse is a good start for maintaining a baseline level of performance and accessibility, but it's not a complete solution and it definitely has its faults."})]})}t.default=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return(0,n.jsx)(h,Object.assign({},e,{children:(0,n.jsx)(l,e)}))}},1151:function(e,t,s){"use strict";s.d(t,{a:function(){return o}});var n=s(7294);let i=n.createContext({});function o(e){let t=n.useContext(i);return n.useMemo(function(){return"function"==typeof e?e(t):{...t,...e}},[t,e])}}},function(e){e.O(0,[325,514,381,42,774,888,179],function(){return e(e.s=3427)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/_next/static/chunks/pages/posts/lighthouse-e186004805166eff.js b/_next/static/chunks/pages/posts/lighthouse-e186004805166eff.js deleted file mode 100644 index d0d88ab2..00000000 --- a/_next/static/chunks/pages/posts/lighthouse-e186004805166eff.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[849],{3427:function(e,t,s){(window.__NEXT_P=window.__NEXT_P||[]).push(["/posts/lighthouse",function(){return s(7794)}])},7794:function(e,t,s){"use strict";s.r(t),s.d(t,{metadata:function(){return r}});var n=s(5893),i=s(1151),o=s(7042);let r={title:"Lighthouse",excerpt:"How to improve your site for everyone using Lighthouse CI reports.",coverImage:"/assets/blog/lighthouse/lighthouse.webp",date:"2021-07-11T17:40:07.322Z",tags:["accessibility","seo","performance"]},h=e=>{let{children:t}=e;return(0,n.jsx)(o.Z,{metadata:r,children:t})};function l(e){let t=Object.assign({h2:"h2",blockquote:"blockquote",p:"p",a:"a",strong:"strong",code:"code",em:"em",ul:"ul",li:"li",pre:"pre"},(0,i.ah)(),e.components);return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(t.h2,{children:"What is Lighthouse?"}),"\n",(0,n.jsxs)(t.blockquote,{children:["\n",(0,n.jsx)(t.p,{children:"Lighthouse analyzes web apps and web pages, collecting modern performance metrics and insights on developer best practices."}),"\n"]}),"\n",(0,n.jsxs)(t.p,{children:["This is the description over on ",(0,n.jsx)(t.a,{href:"https://github.com/GoogleChrome/lighthouse#readme",children:"their README"}),". Unpicking that a bit; Lighthouse is a tool developed by Google which monitors a page as it renders and calculates scores for various categories such as ",(0,n.jsx)(t.strong,{children:"performance"}),", ",(0,n.jsx)(t.strong,{children:"accessibility"}),", and ",(0,n.jsx)(t.strong,{children:"SEO"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["If you're reading this in Chrome, you can run Lighthouse right now in the browser. Right click -> ",(0,n.jsx)(t.code,{children:"Inspect"})," anywhere on the page to open the developer tools, and open the ",(0,n.jsx)(t.code,{children:"Lighthouse"})," tab."]}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/lighthouse/chrome-dev-tools.webp",alt:"How to find Lighthouse in the Chrome dev tools."}),(0,n.jsx)("figcaption",{children:"How to find Lighthouse in the Chrome dev tools."})]}),"\n",(0,n.jsx)(t.p,{children:"Here you'll be able to generate a Lighthouse report against any site you like."}),"\n",(0,n.jsxs)(t.p,{children:["How the scores are calculated and weighted could be the topic of a whole article in itself. In short, scores are out of 100 and higher is better. If you're interested, you can read more about how the performance score is calculated ",(0,n.jsx)(t.a,{href:"https://web.dev/performance-scoring/",children:"here"}),". They also include ",(0,n.jsx)(t.a,{href:"https://googlechrome.github.io/lighthouse/scorecalc/",children:"this handy calculator"})," to see how improvements to various metrics can affect your overall score."]}),"\n",(0,n.jsx)(t.h2,{children:"So what?"}),"\n",(0,n.jsx)(t.p,{children:"So why should we care? Well, these metrics are intended to evaluate how well your site performs for everyone."}),"\n",(0,n.jsxs)(t.p,{children:["Let's use an example. If your site isn't properly accessible, you could be excluding the ",(0,n.jsx)(t.a,{href:"https://nfb.org/resources/blindness-statistics",children:"2.4% of your audience with visual disabilities"}),". And when your potential audience is the entire online population, that is ",(0,n.jsx)("del",{children:"a metric fuckton"})," ",(0,n.jsx)("ins",{children:"nearly 112 million people"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["If that's not enough, here's another important factor: Google performs similar audits and ",(0,n.jsx)(t.a,{href:"https://www.google.com/intl/en_uk/search/howsearchworks/algorithms/",children:"uses this information when deciding how to rank search results"}),". If you want your site to be at the top of Google, focus on making it useful, performant and accessible for everyone first."]}),"\n",(0,n.jsx)(t.h2,{children:"Running an initial scan"}),"\n",(0,n.jsxs)(t.p,{children:["So this all ",(0,n.jsx)(t.em,{children:"sounds"})," great, but there's no way I'm relying on a manual process of running a scan and analysing the results each time. Well, Google provides some tools to run Lighthouse programmatically: ",(0,n.jsx)(t.a,{href:"https://github.com/GoogleChrome/lighthouse-ci",children:"Lighthouse CI"}),"."]}),"\n",(0,n.jsxs)(t.p,{children:["To get started, I followed ",(0,n.jsx)(t.a,{href:"https://web.dev/lighthouse-ci/",children:"this excellent tutorial"})," over on web.dev. This allowed me to set up:"]}),"\n",(0,n.jsxs)(t.ul,{children:["\n",(0,n.jsx)(t.li,{children:"An npm script to run Lighthouse against my site on my machine."}),"\n",(0,n.jsx)(t.li,{children:"A GitHub action to run Lighthouse as part of the existing testing on commits/pull requests."}),"\n",(0,n.jsx)(t.li,{children:"Automatic pushing of the final report to temporary public storage"}),"\n"]}),"\n",(0,n.jsx)(t.p,{children:"And within half an hour or so, I had my first automated result:"}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/lighthouse/first-run.webp",alt:"First Lighthouse run results"}),(0,n.jsx)("figcaption",{children:"Results of the first Lighthouse scan."})]}),"\n",(0,n.jsx)(t.p,{children:"Not bad. But I couldn't help but be slightly disappointed with such a low performance score given the lightweight nature of the site. We'll come back to that later..."}),"\n",(0,n.jsx)(t.h2,{children:"Fixing!"}),"\n",(0,n.jsx)(t.p,{children:"Step 1 - tackle the low hanging fruit:"}),"\n",(0,n.jsxs)(t.ul,{children:["\n",(0,n.jsx)(t.li,{children:(0,n.jsxs)(t.a,{href:"https://web.dev/html-has-lang/",children:[(0,n.jsx)(t.code,{children:""})," element does not have a ",(0,n.jsx)(t.code,{children:"[lang]"})," attribute"]})}),"\n",(0,n.jsx)(t.li,{children:(0,n.jsx)(t.a,{href:"https://web.dev/external-anchors-use-rel-noopener/",children:"Links to cross-origin destinations are unsafe"})}),"\n",(0,n.jsx)(t.li,{children:(0,n.jsx)(t.a,{href:"https://web.dev/link-text/",children:"Links do not have descriptive text"})}),"\n",(0,n.jsx)(t.li,{children:(0,n.jsx)(t.a,{href:"https://web.dev/uses-webp-images/",children:"Serve images in modern formats"})}),"\n"]}),"\n",(0,n.jsxs)(t.p,{children:["There's not too much to say about these. I was most surprised by the savings gained from switching to use ",(0,n.jsx)(t.code,{children:".webp"})," images instead of ",(0,n.jsx)(t.code,{children:".png"}),". ",(0,n.jsx)(t.code,{children:".webp"})," is now ",(0,n.jsx)(t.a,{href:"https://caniuse.com/webp",children:"supported in all major browsers"})," (friendly reminder that IE is not major anymore...) and ",(0,n.jsx)(t.a,{href:"https://insanelab.com/blog/web-development/webp-web-design-vs-jpeg-gif-png/",children:"saves around 26% of the file size"}),". I can't really find a downside other than the slight annoyance of having to run any screen captures through a conversion tool."]}),"\n",(0,n.jsx)(t.p,{children:"Step 2 - spend ages trying to fix the last remaining performance problem only to eventually give up."}),"\n",(0,n.jsxs)(t.p,{children:["Remember our low performance score from earlier? It's actually being caused by an extremely high ",(0,n.jsx)(t.a,{href:"https://web.dev/lcp/",children:"largest contentful paint"})," time of 7.6s. Largest contentful paint is supposed to measure the time at which the largest element containing content renders. This should include images. However, for some reason, on my site, it's not."]}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/lighthouse/largest-contentful-paint.webp",alt:"The largest contentful paint element."}),(0,n.jsx)("figcaption",{children:"No. Bad Lighthouse."})]}),"\n",(0,n.jsxs)(t.p,{children:["Instead it thinks the heading is the LCP element. So it waits whilst it painstakingly types out all the characters of ",(0,n.jsx)(t.code,{children:"hi i'm ash"}),". Replacing this with plain text gives me a performance score of 100. That's also a bit of a false result, because it still thinks the LCP element is the heading."]}),"\n",(0,n.jsx)(t.p,{children:"I will eventually figure out how to make it recognise the images. I suspect it's something to do with the parallax container they're housed in, or the fact they're positioned absolutely. But for now, I've given up."}),"\n",(0,n.jsx)(t.p,{children:"And so, our final results:"}),"\n",(0,n.jsxs)("figure",{children:[(0,n.jsx)("img",{src:"/assets/blog/lighthouse/final-results.webp",alt:"Final Lighthouse run results."}),(0,n.jsx)("figcaption",{children:"The final result. For now."})]}),"\n",(0,n.jsxs)(t.p,{children:["You may notice the performance score has actually gone down even more. That's because I ",(0,n.jsxs)(t.a,{href:"https://github.com/ashharrison90/ashharrison90.github.io/commit/3d6b0b7b44f652fb25f7b7c772d55ddf1f52a359",children:["switched to using a library called ",(0,n.jsx)(t.code,{children:"typewriter-effect"})," to achieve the typewriter animation on the home page"]}),". It intentionally completes the animation slightly slower than before, which causes Lighthouse to return a lower score."]}),"\n",(0,n.jsx)(t.h2,{children:"Preventing regressions with presets"}),"\n",(0,n.jsxs)(t.p,{children:["Over in the README for Lighthouse CI there's a section on ",(0,n.jsx)(t.a,{href:"https://github.com/GoogleChrome/lighthouse-ci/blob/main/docs/configuration.md#next-level",children:"next steps"}),". Here it advocates using the ",(0,n.jsx)(t.code,{children:"lighthouse:recommended"})," preset. This sets up a bunch of assertions on the collected metrics and fails the run if they dip below a certain threshold."]}),"\n",(0,n.jsx)(t.p,{children:"There's 2 things to note:"}),"\n",(0,n.jsxs)(t.ul,{children:["\n",(0,n.jsx)(t.li,{children:"You can disable individual rules with something like this in your config file:"}),"\n"]}),"\n",(0,n.jsx)(t.pre,{children:(0,n.jsx)(t.code,{className:"language-json",children:'{\n "ci": {\n "assert": {\n "preset": "lighthouse:recommended",\n "assertions": {\n "uses-webp-images": "off"\n }\n }\n ...\n }\n}\n'})}),"\n",(0,n.jsxs)(t.ul,{children:["\n",(0,n.jsxs)(t.li,{children:["I would actually recommend using the ",(0,n.jsx)(t.code,{children:"lighthouse:no-pwa"})," preset instead. This has all the recommended settings, but without the progressive web app section which can be ",(0,n.jsx)(t.a,{href:"https://github.com/GoogleChrome/lighthouse-ci/issues/51",children:"a little strict"}),"."]}),"\n"]}),"\n",(0,n.jsx)(t.h2,{children:"Final thoughts"}),"\n",(0,n.jsx)(t.p,{children:"Lighthouse is a good start for maintaining a baseline level of performance and accessibility, but it's not a complete solution and it definitely has its faults."})]})}t.default=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return(0,n.jsx)(h,Object.assign({},e,{children:(0,n.jsx)(l,e)}))}},1151:function(e,t,s){"use strict";s.d(t,{ah:function(){return o}});var n=s(7294);let i=n.createContext({});function o(e){let t=n.useContext(i);return n.useMemo(()=>"function"==typeof e?e(t):{...t,...e},[t,e])}}},function(e){e.O(0,[325,514,381,42,774,888,179],function(){return e(e.s=3427)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/_next/static/chunks/pages/posts/mr-robot-a5268076acb29c33.js b/_next/static/chunks/pages/posts/mr-robot-a5268076acb29c33.js deleted file mode 100644 index 0abd9d95..00000000 --- a/_next/static/chunks/pages/posts/mr-robot-a5268076acb29c33.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[609],{2607:function(e,n,t){(window.__NEXT_P=window.__NEXT_P||[]).push(["/posts/mr-robot",function(){return t(7086)}])},7086:function(e,n,t){"use strict";t.r(n),t.d(n,{metadata:function(){return i}});var a=t(5893),r=t(1151),s=t(7042);let i={title:"Mr Robot v3.0",excerpt:"Using neural networks to pick my fantasy football team.",coverImage:"/assets/blog/mr-robot/elliot.webp",date:"2021-08-24T17:40:07.322Z",tags:["python","machinelearning"]},o=e=>{let{children:n}=e;return(0,a.jsx)(s.Z,{metadata:i,children:n})};function l(e){let n=Object.assign({h2:"h2",p:"p",table:"table",thead:"thead",tr:"tr",th:"th",tbody:"tbody",td:"td",a:"a",ol:"ol",li:"li",strong:"strong",h3:"h3",pre:"pre",code:"code",em:"em",ul:"ul"},(0,r.ah)(),e.components);return(0,a.jsxs)(a.Fragment,{children:[(0,a.jsx)(n.h2,{children:"Fantasy football overview"}),"\n",(0,a.jsx)(n.p,{children:"Now that the 2021/2022 Premier League season has officially started, it seems appropriate to talk about this side project in a little more detail."}),"\n",(0,a.jsx)(n.p,{children:"For anyone unfamiliar with fantasy football, it's a game where you pick a hypothetical team of 15 players with the aim of maximising the number of points scored over the course of the season. Each player in your team gains points based on the actions described in the table below."}),"\n",(0,a.jsxs)(n.table,{children:[(0,a.jsx)(n.thead,{children:(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.th,{children:"Action"}),(0,a.jsx)(n.th,{children:"Points"})]})}),(0,a.jsxs)(n.tbody,{children:[(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Playing up to 60 minutes"}),(0,a.jsx)(n.td,{children:"1"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Playing 60 minutes or more (excluding stoppage time)"}),(0,a.jsx)(n.td,{children:"2"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Goal scored by a goalkeeper or defender"}),(0,a.jsx)(n.td,{children:"6"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Goal scored by a midfielder"}),(0,a.jsx)(n.td,{children:"5"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Goal scored by a forward"}),(0,a.jsx)(n.td,{children:"4"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Goal assist"}),(0,a.jsx)(n.td,{children:"3"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Clean sheet by a goalkeeper or defender"}),(0,a.jsx)(n.td,{children:"4"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Clean sheet by a midfielder"}),(0,a.jsx)(n.td,{children:"1"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"3 shot saves by a goalkeeper"}),(0,a.jsx)(n.td,{children:"1"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Penalty save"}),(0,a.jsx)(n.td,{children:"5"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Penalty miss"}),(0,a.jsx)(n.td,{children:"-2"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Bonus points for the best players in a match"}),(0,a.jsx)(n.td,{children:"1-3"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"2 goals conceded by a goalkeeper or defender"}),(0,a.jsx)(n.td,{children:"-1"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Yellow card"}),(0,a.jsx)(n.td,{children:"-1"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Red card"}),(0,a.jsx)(n.td,{children:"-3"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Own goal"}),(0,a.jsx)(n.td,{children:"-2"})]})]})]}),"\n",(0,a.jsxs)(n.p,{children:["Of course, there are certain constraints to make the game challenging. Everyone starts the season with the same initial budget. You must have the correct number of players in each position and can't have more than 3 players from the same team. You can find the full list of rules ",(0,a.jsx)(n.a,{href:"https://fantasy.premierleague.com/help/rules",children:"over on the website"}),"."]}),"\n",(0,a.jsx)(n.p,{children:"So what does this have to do with Mr Robot? Nothing. It's just the name of my team."}),"\n",(0,a.jsx)(n.h2,{children:"Mr Robot v1.0"}),"\n",(0,a.jsx)(n.p,{children:"We'll start with what exists already. Back at the start of the 2016/2017 season, I wanted to write something in Python. I set myself the challenge of writing a fantasy football bot in a week. It would decide the team and interact with the fantasy premier league API to make the necessary transfers throughout the season with zero manual intervention."}),"\n",(0,a.jsx)(n.p,{children:"The code is broadly split into 3 parts:"}),"\n",(0,a.jsxs)(n.ol,{children:["\n",(0,a.jsxs)(n.li,{children:[(0,a.jsx)(n.strong,{children:"Web service module"}),". This is responsible for interacting with the various endpoints the fantasy premier league API provides. It logs in, scrapes player data, fixture data, the current team, makes any transfers and sets the new team and starting lineup."]}),"\n",(0,a.jsxs)(n.li,{children:[(0,a.jsx)(n.strong,{children:"Points prediction module"}),". Given a specific player and fixture data, predict how many points that player will score."]}),"\n",(0,a.jsxs)(n.li,{children:[(0,a.jsx)(n.strong,{children:"Linear solver module"}),". Once we have a list of players and points, construct the best possible team given the various constraints."]}),"\n"]}),"\n",(0,a.jsx)(n.p,{children:"Let's look at the last two in a bit more detail."}),"\n",(0,a.jsx)(n.h3,{children:"Points prediction"}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"def predict_points(player, fixture_data, gameweek=0):\n form = float(player['form'])\n ppg = float(player['points_per_game'])\n expected_points = max(form, ppg)\n\n injury_ratio = calculate_injury_multiplier(player)\n fixture_ratio = calculate_fixture_multiplier(player, fixture_data, gameweek)\n past_fixture_ratio = calculate_past_fixture_multiplier(player, fixture_data)\n\n result = expected_points * injury_ratio * fixture_ratio * past_fixture_ratio\n return result\n"})}),"\n",(0,a.jsxs)(n.p,{children:["Given a player, we take the max of their ",(0,a.jsx)(n.code,{children:"form"})," (effectively a running average over the last 3 games) and their ",(0,a.jsx)(n.code,{children:"points_per_game"})," (their mean point score over the season so far). The idea being this would allow in-form players to be substituted in over players with a higher ",(0,a.jsx)(n.code,{children:"points_per_game"}),"."]}),"\n",(0,a.jsxs)(n.p,{children:["There are also some ratios that normalise the score a bit. ",(0,a.jsx)(n.strong,{children:(0,a.jsx)(n.code,{children:"injury_ratio"})})," is a value between 0 and 1 describing how likely a player is to play given any injury information. ",(0,a.jsx)(n.strong,{children:(0,a.jsx)(n.code,{children:"fixture_ratio"})})," is a ratio calculated from the two teams Elo ratings. Players on higher rated teams ",(0,a.jsx)(n.em,{children:"should"})," score more points when playing against lower rated teams and vice-versa. Finally, ",(0,a.jsx)(n.strong,{children:(0,a.jsx)(n.code,{children:"past_fixture_ratio"})})," is a ratio of minutes played against total minutes. This is to prevent adding players who may have only played one game but scored highly."]}),"\n",(0,a.jsx)(n.p,{children:"I know, I know. It's a pretty shit implementation. But I only had a week. Give me a break."}),"\n",(0,a.jsx)(n.h3,{children:"Linear solver"}),"\n",(0,a.jsxs)(n.p,{children:["Fortunately, the linear solver is actually pretty non-shit. It uses a Python module called ",(0,a.jsx)(n.a,{href:"https://coin-or.github.io/pulp/",children:"PuLP"})," to define and solve a ",(0,a.jsx)(n.a,{href:"https://en.wikipedia.org/wiki/Linear_programming",children:"linear optimisation problem"}),"."]}),"\n",(0,a.jsx)(n.p,{children:"Let's start by defining the optimisation problem. We want to maximise the total points scored:"}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"# Define the squad linear optimisation problem\nsquad_prob = pulp.LpProblem('squad', pulp.LpMaximize)\n"})}),"\n",(0,a.jsx)(n.p,{children:"We then loop through each player and add their data to a set of linear equations:"}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"# Loop through every player and add them to the constraints\nall_players = web_service.get_all_player_data()['elements']\nfor player in all_players:\n fixture_data = web_service.get_player_fixtures(player['id'])\n player['expected_points_this_gameweek'] = points.predict_points(player, fixture_data)\n player['selected'] = pulp.LpVariable('player_' + str(player['id']), cat='Binary')\n teams_represented[player['team'] - 1] += player['selected']\n player_type = player['element_type']\n new_squad_points += player['selected'] * player['expected_points_this_gameweek']\n\n if player_type == 1:\n num_goal += player['selected']\n elif player_type == 2:\n num_def += player['selected']\n elif player_type == 3:\n num_mid += player['selected']\n elif player_type == 4:\n num_att += player['selected']\n\n if player['id'] in current_squad_ids:\n index = current_squad_ids.index(player['id'])\n selling_price = current_squad['picks'][index]['selling_price']\n bank += (1 - player['selected']) * selling_price\n squad_value -= (1 - player['selected']) * player['now_cost']\n else:\n num_changes += player['selected']\n bank -= player['selected'] * player['now_cost']\n squad_value += player['selected'] * player['now_cost']\n"})}),"\n",(0,a.jsxs)(n.p,{children:["The key part here is the creation of the binary variable ",(0,a.jsx)(n.code,{children:"player['selected']"})," determining whether to select the player or not. This variable either takes the value of 0 or 1. So when evaluating the overall squad points we have an equation of the form:"]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"new_squad_points =\n player_1['selected'] * player_1['expected_points'] +\n player_2['selected'] * player_2['expected_points'] +\n player_3['selected'] * player_3['expected_points'] +\n ...\n player_n['selected'] * player_n['expected_points']\n"})}),"\n",(0,a.jsxs)(n.p,{children:["By setting different values of ",(0,a.jsx)(n.code,{children:"selected"})," for each player, we get different squad points. But what's preventing the solver from selecting every player to give the maximum amount of points? The constraints. These are:"]}),"\n",(0,a.jsxs)(n.ul,{children:["\n",(0,a.jsx)(n.li,{children:"Must not have more than 3 players from the same team"}),"\n",(0,a.jsx)(n.li,{children:"Must have enough bank for the team"}),"\n",(0,a.jsx)(n.li,{children:"Must not have a negative bank"}),"\n",(0,a.jsx)(n.li,{children:"Must have 2 goalkeepers"}),"\n",(0,a.jsx)(n.li,{children:"Must have 5 defenders"}),"\n",(0,a.jsx)(n.li,{children:"Must have 5 midfielders"}),"\n",(0,a.jsx)(n.li,{children:"Must have 3 forwards"}),"\n",(0,a.jsx)(n.li,{children:"Must only use the free transfers when making changes"}),"\n"]}),"\n",(0,a.jsx)(n.p,{children:"In code:"}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"# Account for free transfers and cost transfers\nfree_transfers_used = pulp.LpVariable(\n 'free_transfers_used',\n cat='Integer',\n lowBound=0,\n upBound=free_transfers\n)\ntransfer_cost = ((num_changes - free_transfers_used)\n * constants.TRANSFER_POINT_DEDUCTION)\n\n# Add problem and constraints\nsquad_prob += new_squad_points - transfer_cost\nfor team_count in teams_represented:\n squad_prob += (team_count <= constants.SQUAD_MAX_PLAYERS_SAME_TEAM)\nsquad_prob += (squad_value + bank <= total_bank)\nsquad_prob += (bank >= 0)\nsquad_prob += (num_goal == constants.SQUAD_NUM_GOALKEEPERS)\nsquad_prob += (num_def == constants.SQUAD_NUM_DEFENDERS)\nsquad_prob += (num_mid == constants.SQUAD_NUM_MIDFIELDERS)\nsquad_prob += (num_att == constants.SQUAD_NUM_ATTACKERS)\nsquad_prob += (num_changes - free_transfers_used >= 0)\n\n# Solve\nsquad_prob.solve()\n"})}),"\n",(0,a.jsx)(n.h3,{children:"Performance"}),"\n",(0,a.jsx)(n.p,{children:"This would then run nightly as a cron job on a Raspberry Pi. It compares the current date to the next transfer deadline date scraped by the web service. If the days match (i.e. we're on the day of the transfer deadline) it updates the squad."}),"\n",(0,a.jsx)(n.p,{children:"So how has it been doing?"}),"\n",(0,a.jsxs)("figure",{children:[(0,a.jsx)("img",{src:"/assets/blog/mr-robot/past-performance.webp",alt:"Past performance has been mixed..."}),(0,a.jsx)("figcaption",{children:"Past performance has been mixed..."})]}),"\n",(0,a.jsxs)(n.p,{children:["I mean... not ",(0,a.jsx)(n.em,{children:"great"}),". To put those numbers into context, there are usually around 6-7 million total players. So top ~40% in its first season then top ~25% for 2 seasons. The final dip is a purely human error: during the season I moved and forgot to plug the Raspberry Pi back in."]}),"\n",(0,a.jsx)(n.p,{children:"Either way, I think we can do better."}),"\n",(0,a.jsx)(n.h2,{children:"Improvements"}),"\n",(0,a.jsx)(n.p,{children:"Even though I think the linear solver is pretty solid at this point, there is still a small improvement we can make. Whilst we maximise the points for the entire squad, on any given gameweek only the starting 11 players points will contribute to the overall score. Most of the best fantasy football players will therefore try to optimise the score of their starting 11."}),"\n",(0,a.jsx)(n.p,{children:'This was particularly difficult to formulate as a constraint. Instead I ended up requiring that there were at least 4 "cheap" players in the squad at any time:'}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"num_cheap = 0\n\nfor player in all_players:\n\n ...\n\n if player['now_cost'] <= 51.00:\n num_cheap += player['selected']\n\n ...\n\n squad_prob += (num_cheap >= 4)\n"})}),"\n",(0,a.jsxs)(n.p,{children:["These will ",(0,a.jsx)(n.em,{children:"probably"}),", although not ",(0,a.jsx)(n.em,{children:"necessarily"}),", be the players on the bench. That leaves us with at least 79.6/11 = 7.24 million per player for the starting 11, as opposed to 100/15 = 6.67 million per player when the budget is shared equally between the whole squad."]}),"\n",(0,a.jsx)(n.p,{children:"But now that we know a bit more about the code, I think it's pretty obvious that the majority of the improvements will need to be made in the points prediction module. So how can we more accurately predict the points each player will score each week? By outsourcing the work to a neural network!"}),"\n",(0,a.jsx)(n.h2,{children:"PyTorch"}),"\n",(0,a.jsxs)(n.p,{children:["Enter PyTorch. ",(0,a.jsx)(n.a,{href:"https://pytorch.org/",children:"PyTorch"})," is an open source machine learning framework developed by Facebook. Machine learning works by analysing a set of training data to build a model of the data that can then be used to make future predictions. This sort of problem is particularly suited to machine learning as it can recognise patterns humans might miss."]}),"\n",(0,a.jsx)(n.p,{children:"First, we define the model class."}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"from torch.nn import BatchNorm1d, Dropout, Embedding, Linear, Module, ModuleList, ReLU, Sequential\nimport torch\n\nclass Model(Module):\n def __init__(self, embedding_size, num_numerical_cols):\n super().__init__()\n self.all_embeddings = ModuleList([Embedding(ni, nf) for ni, nf in embedding_size])\n self.embedding_dropout = Dropout(0.1)\n self.batch_norm_num = BatchNorm1d(num_numerical_cols)\n\n all_layers = []\n num_categorical_cols = sum((nf for ni, nf in embedding_size))\n layer_size = num_categorical_cols + num_numerical_cols\n\n layer_sizes = [100, 50, 25]\n for i, new_layer_size in enumerate(layer_sizes):\n all_layers.append(Linear(layer_size, new_layer_size))\n all_layers.append(ReLU(inplace=True))\n all_layers.append(BatchNorm1d(new_layer_size))\n all_layers.append(Dropout(0.2))\n layer_size = new_layer_size\n\n all_layers.append(Linear(layer_sizes[-1], 1))\n self.layers = Sequential(*all_layers)\n\n def forward(self, x_categorical, x_numerical):\n embeddings = []\n for i, e in enumerate(self.all_embeddings):\n embeddings.append(e(x_categorical[:,i]))\n x = torch.cat(embeddings, 1)\n x = self.embedding_dropout(x)\n x_numerical = self.batch_norm_num(x_numerical)\n x = torch.cat([x, x_numerical], 1)\n x = self.layers(x)\n return x\n"})}),"\n",(0,a.jsxs)(n.p,{children:["In basic terms, we're taking the input data and applying a set of transformations. These are known as layers. The constructor sets up the layers which can then be used by the ",(0,a.jsx)(n.code,{children:"forward"})," function. This ",(0,a.jsx)(n.code,{children:"forward"})," function is effectively what is invoked when training the model. You can find lots of information on the different layer types ",(0,a.jsx)(n.a,{href:"https://pytorch.org/docs/stable/nn.html",children:"in the docs"}),"."]}),"\n",(0,a.jsxs)(n.p,{children:["One layer that's pretty interesting (and actually understandable) is the Dropout layer. ",(0,a.jsx)(n.a,{href:"https://jmlr.org/papers/v15/srivastava14a.html",children:"Neural networks are generally prone to overfitting the training data"}),". The idea of a dropout layer is to randomly remove some neurons in the network during training to prevent these neurons from coadaptation. ",(0,a.jsx)(n.a,{href:"https://medium.com/@lipeng2/dropout-is-so-important-e517bbe3ffcc",children:"Here's an excellent article about this by Michael Peng over on medium"}),"."]}),"\n",(0,a.jsxs)(n.p,{children:["Anyway, back to the transformation layers. At the start we don't know exactly what values these transformations should have. We define a ",(0,a.jsx)(n.a,{href:"https://en.wikipedia.org/wiki/Loss_function",children:"loss function"})," that tells us how far away our predicted value is from the real thing, and each time the model runs against the training data it attempts to minimise the loss function. This is known as a ",(0,a.jsx)(n.strong,{children:"training loop"})," or ",(0,a.jsx)(n.strong,{children:"epoch"}),"."]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"# train the model\ndef train_model():\n logger.info('Training the model. This could take a long time...')\n model.train()\n # define the optimization\n loss_function = MSELoss()\n optimizer = SGD(model.parameters(), lr=0.001, momentum=0.9)\n epochs = 500\n # enumerate epochs\n for epoch in range(epochs):\n # compute the model output\n predictions = model(categorical_training_data, numerical_training_data).squeeze()\n # calculate loss compared to actual outputs\n loss = loss_function(predictions, training_outputs)\n logger.info('Epoch: {}/{}. Loss: {:.2f}'.format(epoch+1, epochs, loss))\n # clear the gradients\n optimizer.zero_grad()\n # credit assignment\n loss.backward()\n # update model weights\n optimizer.step()\n"})}),"\n",(0,a.jsxs)(n.p,{children:["Finally we need data in order to train the model. Huge thanks to ",(0,a.jsx)(n.a,{href:"https://github.com/vaastav",children:"vaastav"})," who has been scraping data since the 2016/2017 season over on the ",(0,a.jsx)(n.a,{href:"https://github.com/vaastav/Fantasy-Premier-League",children:"Fantasy-Premier-League"})," project."]}),"\n",(0,a.jsxs)(n.p,{children:["Maybe you've noticed I've skipped over ",(0,a.jsx)(n.strong,{children:"a lot"})," of details. I'm not a data scientist. I can't tell you what layers to use, how to pick the number of neurons in each layer, why to pick a certain learning rate or how many epochs are required because I honestly don't know. The numbers above are purely the result of trial and error."]}),"\n",(0,a.jsx)(n.h2,{children:"Results"}),"\n",(0,a.jsxs)("figure",{children:[(0,a.jsx)("img",{src:"/assets/blog/mr-robot/team-final.webp",alt:"Final team selection"}),(0,a.jsx)("figcaption",{children:(0,a.jsx)(n.p,{children:"The final team selection and their point totals after the first gameweek."})})]}),"\n",(0,a.jsxs)(n.p,{children:["Here's the team and their point totals for week 1. This gave an overall gameweek score of 82, which puts the team at rank 2,004,686 out of ~7 million active players. Definitely not mindblowing, but a promising start nonetheless. Fantasy football is all about consistency. If the team consistently scores 82 that would be well over 3000 points by the end of the season. ",(0,a.jsx)(n.a,{href:"https://www.reddit.com/r/FantasyPL/comments/bowc35/points_needed_to_win_fpl/",children:"Easily enough to win the entire competition"}),"."]}),"\n",(0,a.jsxs)(n.p,{children:["Earlier we talked about patterns and how machine learning can recognise things we might miss. One thing I definitely missed was that ",(0,a.jsx)(n.a,{href:"https://www.liverpoolfc.com/news/first-team/440750-mohamed-salah-could-set-premier-league-opening-day-record",children:"Mo Salah has scored in the opening game of the previous 4 seasons"}),". This season, Salah ended up being the second highest points scorer in the opening gameweek."]}),"\n",(0,a.jsxs)(n.p,{children:["I'll try to remember to write an update post at the end of the season. In the mean time, you can ",(0,a.jsx)(n.a,{href:"https://github.com/ashharrison90/fantasy_pl",children:"check out all the code here."})]})]})}n.default=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return(0,a.jsx)(o,Object.assign({},e,{children:(0,a.jsx)(l,e)}))}},1151:function(e,n,t){"use strict";t.d(n,{ah:function(){return s}});var a=t(7294);let r=a.createContext({});function s(e){let n=a.useContext(r);return a.useMemo(()=>"function"==typeof e?e(n):{...n,...e},[n,e])}}},function(e){e.O(0,[325,514,381,42,774,888,179],function(){return e(e.s=2607)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/_next/static/chunks/pages/posts/mr-robot-fd5545a761b7692a.js b/_next/static/chunks/pages/posts/mr-robot-fd5545a761b7692a.js new file mode 100644 index 00000000..aafa2e8c --- /dev/null +++ b/_next/static/chunks/pages/posts/mr-robot-fd5545a761b7692a.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[609],{2607:function(e,n,t){(window.__NEXT_P=window.__NEXT_P||[]).push(["/posts/mr-robot",function(){return t(7086)}])},7086:function(e,n,t){"use strict";t.r(n),t.d(n,{metadata:function(){return i}});var a=t(5893),r=t(1151),s=t(7042);let i={title:"Mr Robot v3.0",excerpt:"Using neural networks to pick my fantasy football team.",coverImage:"/assets/blog/mr-robot/elliot.webp",date:"2021-08-24T17:40:07.322Z",tags:["python","machinelearning"]},o=e=>{let{children:n}=e;return(0,a.jsx)(s.Z,{metadata:i,children:n})};function l(e){let n=Object.assign({h2:"h2",p:"p",table:"table",thead:"thead",tr:"tr",th:"th",tbody:"tbody",td:"td",a:"a",ol:"ol",li:"li",strong:"strong",h3:"h3",pre:"pre",code:"code",em:"em",ul:"ul"},(0,r.a)(),e.components);return(0,a.jsxs)(a.Fragment,{children:[(0,a.jsx)(n.h2,{children:"Fantasy football overview"}),"\n",(0,a.jsx)(n.p,{children:"Now that the 2021/2022 Premier League season has officially started, it seems appropriate to talk about this side project in a little more detail."}),"\n",(0,a.jsx)(n.p,{children:"For anyone unfamiliar with fantasy football, it's a game where you pick a hypothetical team of 15 players with the aim of maximising the number of points scored over the course of the season. Each player in your team gains points based on the actions described in the table below."}),"\n",(0,a.jsxs)(n.table,{children:[(0,a.jsx)(n.thead,{children:(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.th,{children:"Action"}),(0,a.jsx)(n.th,{children:"Points"})]})}),(0,a.jsxs)(n.tbody,{children:[(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Playing up to 60 minutes"}),(0,a.jsx)(n.td,{children:"1"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Playing 60 minutes or more (excluding stoppage time)"}),(0,a.jsx)(n.td,{children:"2"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Goal scored by a goalkeeper or defender"}),(0,a.jsx)(n.td,{children:"6"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Goal scored by a midfielder"}),(0,a.jsx)(n.td,{children:"5"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Goal scored by a forward"}),(0,a.jsx)(n.td,{children:"4"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Goal assist"}),(0,a.jsx)(n.td,{children:"3"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Clean sheet by a goalkeeper or defender"}),(0,a.jsx)(n.td,{children:"4"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Clean sheet by a midfielder"}),(0,a.jsx)(n.td,{children:"1"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"3 shot saves by a goalkeeper"}),(0,a.jsx)(n.td,{children:"1"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Penalty save"}),(0,a.jsx)(n.td,{children:"5"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Penalty miss"}),(0,a.jsx)(n.td,{children:"-2"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Bonus points for the best players in a match"}),(0,a.jsx)(n.td,{children:"1-3"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"2 goals conceded by a goalkeeper or defender"}),(0,a.jsx)(n.td,{children:"-1"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Yellow card"}),(0,a.jsx)(n.td,{children:"-1"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Red card"}),(0,a.jsx)(n.td,{children:"-3"})]}),(0,a.jsxs)(n.tr,{children:[(0,a.jsx)(n.td,{children:"Own goal"}),(0,a.jsx)(n.td,{children:"-2"})]})]})]}),"\n",(0,a.jsxs)(n.p,{children:["Of course, there are certain constraints to make the game challenging. Everyone starts the season with the same initial budget. You must have the correct number of players in each position and can't have more than 3 players from the same team. You can find the full list of rules ",(0,a.jsx)(n.a,{href:"https://fantasy.premierleague.com/help/rules",children:"over on the website"}),"."]}),"\n",(0,a.jsx)(n.p,{children:"So what does this have to do with Mr Robot? Nothing. It's just the name of my team."}),"\n",(0,a.jsx)(n.h2,{children:"Mr Robot v1.0"}),"\n",(0,a.jsx)(n.p,{children:"We'll start with what exists already. Back at the start of the 2016/2017 season, I wanted to write something in Python. I set myself the challenge of writing a fantasy football bot in a week. It would decide the team and interact with the fantasy premier league API to make the necessary transfers throughout the season with zero manual intervention."}),"\n",(0,a.jsx)(n.p,{children:"The code is broadly split into 3 parts:"}),"\n",(0,a.jsxs)(n.ol,{children:["\n",(0,a.jsxs)(n.li,{children:[(0,a.jsx)(n.strong,{children:"Web service module"}),". This is responsible for interacting with the various endpoints the fantasy premier league API provides. It logs in, scrapes player data, fixture data, the current team, makes any transfers and sets the new team and starting lineup."]}),"\n",(0,a.jsxs)(n.li,{children:[(0,a.jsx)(n.strong,{children:"Points prediction module"}),". Given a specific player and fixture data, predict how many points that player will score."]}),"\n",(0,a.jsxs)(n.li,{children:[(0,a.jsx)(n.strong,{children:"Linear solver module"}),". Once we have a list of players and points, construct the best possible team given the various constraints."]}),"\n"]}),"\n",(0,a.jsx)(n.p,{children:"Let's look at the last two in a bit more detail."}),"\n",(0,a.jsx)(n.h3,{children:"Points prediction"}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"def predict_points(player, fixture_data, gameweek=0):\n form = float(player['form'])\n ppg = float(player['points_per_game'])\n expected_points = max(form, ppg)\n\n injury_ratio = calculate_injury_multiplier(player)\n fixture_ratio = calculate_fixture_multiplier(player, fixture_data, gameweek)\n past_fixture_ratio = calculate_past_fixture_multiplier(player, fixture_data)\n\n result = expected_points * injury_ratio * fixture_ratio * past_fixture_ratio\n return result\n"})}),"\n",(0,a.jsxs)(n.p,{children:["Given a player, we take the max of their ",(0,a.jsx)(n.code,{children:"form"})," (effectively a running average over the last 3 games) and their ",(0,a.jsx)(n.code,{children:"points_per_game"})," (their mean point score over the season so far). The idea being this would allow in-form players to be substituted in over players with a higher ",(0,a.jsx)(n.code,{children:"points_per_game"}),"."]}),"\n",(0,a.jsxs)(n.p,{children:["There are also some ratios that normalise the score a bit. ",(0,a.jsx)(n.strong,{children:(0,a.jsx)(n.code,{children:"injury_ratio"})})," is a value between 0 and 1 describing how likely a player is to play given any injury information. ",(0,a.jsx)(n.strong,{children:(0,a.jsx)(n.code,{children:"fixture_ratio"})})," is a ratio calculated from the two teams Elo ratings. Players on higher rated teams ",(0,a.jsx)(n.em,{children:"should"})," score more points when playing against lower rated teams and vice-versa. Finally, ",(0,a.jsx)(n.strong,{children:(0,a.jsx)(n.code,{children:"past_fixture_ratio"})})," is a ratio of minutes played against total minutes. This is to prevent adding players who may have only played one game but scored highly."]}),"\n",(0,a.jsx)(n.p,{children:"I know, I know. It's a pretty shit implementation. But I only had a week. Give me a break."}),"\n",(0,a.jsx)(n.h3,{children:"Linear solver"}),"\n",(0,a.jsxs)(n.p,{children:["Fortunately, the linear solver is actually pretty non-shit. It uses a Python module called ",(0,a.jsx)(n.a,{href:"https://coin-or.github.io/pulp/",children:"PuLP"})," to define and solve a ",(0,a.jsx)(n.a,{href:"https://en.wikipedia.org/wiki/Linear_programming",children:"linear optimisation problem"}),"."]}),"\n",(0,a.jsx)(n.p,{children:"Let's start by defining the optimisation problem. We want to maximise the total points scored:"}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"# Define the squad linear optimisation problem\nsquad_prob = pulp.LpProblem('squad', pulp.LpMaximize)\n"})}),"\n",(0,a.jsx)(n.p,{children:"We then loop through each player and add their data to a set of linear equations:"}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"# Loop through every player and add them to the constraints\nall_players = web_service.get_all_player_data()['elements']\nfor player in all_players:\n fixture_data = web_service.get_player_fixtures(player['id'])\n player['expected_points_this_gameweek'] = points.predict_points(player, fixture_data)\n player['selected'] = pulp.LpVariable('player_' + str(player['id']), cat='Binary')\n teams_represented[player['team'] - 1] += player['selected']\n player_type = player['element_type']\n new_squad_points += player['selected'] * player['expected_points_this_gameweek']\n\n if player_type == 1:\n num_goal += player['selected']\n elif player_type == 2:\n num_def += player['selected']\n elif player_type == 3:\n num_mid += player['selected']\n elif player_type == 4:\n num_att += player['selected']\n\n if player['id'] in current_squad_ids:\n index = current_squad_ids.index(player['id'])\n selling_price = current_squad['picks'][index]['selling_price']\n bank += (1 - player['selected']) * selling_price\n squad_value -= (1 - player['selected']) * player['now_cost']\n else:\n num_changes += player['selected']\n bank -= player['selected'] * player['now_cost']\n squad_value += player['selected'] * player['now_cost']\n"})}),"\n",(0,a.jsxs)(n.p,{children:["The key part here is the creation of the binary variable ",(0,a.jsx)(n.code,{children:"player['selected']"})," determining whether to select the player or not. This variable either takes the value of 0 or 1. So when evaluating the overall squad points we have an equation of the form:"]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"new_squad_points =\n player_1['selected'] * player_1['expected_points'] +\n player_2['selected'] * player_2['expected_points'] +\n player_3['selected'] * player_3['expected_points'] +\n ...\n player_n['selected'] * player_n['expected_points']\n"})}),"\n",(0,a.jsxs)(n.p,{children:["By setting different values of ",(0,a.jsx)(n.code,{children:"selected"})," for each player, we get different squad points. But what's preventing the solver from selecting every player to give the maximum amount of points? The constraints. These are:"]}),"\n",(0,a.jsxs)(n.ul,{children:["\n",(0,a.jsx)(n.li,{children:"Must not have more than 3 players from the same team"}),"\n",(0,a.jsx)(n.li,{children:"Must have enough bank for the team"}),"\n",(0,a.jsx)(n.li,{children:"Must not have a negative bank"}),"\n",(0,a.jsx)(n.li,{children:"Must have 2 goalkeepers"}),"\n",(0,a.jsx)(n.li,{children:"Must have 5 defenders"}),"\n",(0,a.jsx)(n.li,{children:"Must have 5 midfielders"}),"\n",(0,a.jsx)(n.li,{children:"Must have 3 forwards"}),"\n",(0,a.jsx)(n.li,{children:"Must only use the free transfers when making changes"}),"\n"]}),"\n",(0,a.jsx)(n.p,{children:"In code:"}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"# Account for free transfers and cost transfers\nfree_transfers_used = pulp.LpVariable(\n 'free_transfers_used',\n cat='Integer',\n lowBound=0,\n upBound=free_transfers\n)\ntransfer_cost = ((num_changes - free_transfers_used)\n * constants.TRANSFER_POINT_DEDUCTION)\n\n# Add problem and constraints\nsquad_prob += new_squad_points - transfer_cost\nfor team_count in teams_represented:\n squad_prob += (team_count <= constants.SQUAD_MAX_PLAYERS_SAME_TEAM)\nsquad_prob += (squad_value + bank <= total_bank)\nsquad_prob += (bank >= 0)\nsquad_prob += (num_goal == constants.SQUAD_NUM_GOALKEEPERS)\nsquad_prob += (num_def == constants.SQUAD_NUM_DEFENDERS)\nsquad_prob += (num_mid == constants.SQUAD_NUM_MIDFIELDERS)\nsquad_prob += (num_att == constants.SQUAD_NUM_ATTACKERS)\nsquad_prob += (num_changes - free_transfers_used >= 0)\n\n# Solve\nsquad_prob.solve()\n"})}),"\n",(0,a.jsx)(n.h3,{children:"Performance"}),"\n",(0,a.jsx)(n.p,{children:"This would then run nightly as a cron job on a Raspberry Pi. It compares the current date to the next transfer deadline date scraped by the web service. If the days match (i.e. we're on the day of the transfer deadline) it updates the squad."}),"\n",(0,a.jsx)(n.p,{children:"So how has it been doing?"}),"\n",(0,a.jsxs)("figure",{children:[(0,a.jsx)("img",{src:"/assets/blog/mr-robot/past-performance.webp",alt:"Past performance has been mixed..."}),(0,a.jsx)("figcaption",{children:"Past performance has been mixed..."})]}),"\n",(0,a.jsxs)(n.p,{children:["I mean... not ",(0,a.jsx)(n.em,{children:"great"}),". To put those numbers into context, there are usually around 6-7 million total players. So top ~40% in its first season then top ~25% for 2 seasons. The final dip is a purely human error: during the season I moved and forgot to plug the Raspberry Pi back in."]}),"\n",(0,a.jsx)(n.p,{children:"Either way, I think we can do better."}),"\n",(0,a.jsx)(n.h2,{children:"Improvements"}),"\n",(0,a.jsx)(n.p,{children:"Even though I think the linear solver is pretty solid at this point, there is still a small improvement we can make. Whilst we maximise the points for the entire squad, on any given gameweek only the starting 11 players points will contribute to the overall score. Most of the best fantasy football players will therefore try to optimise the score of their starting 11."}),"\n",(0,a.jsx)(n.p,{children:'This was particularly difficult to formulate as a constraint. Instead I ended up requiring that there were at least 4 "cheap" players in the squad at any time:'}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"num_cheap = 0\n\nfor player in all_players:\n\n ...\n\n if player['now_cost'] <= 51.00:\n num_cheap += player['selected']\n\n ...\n\n squad_prob += (num_cheap >= 4)\n"})}),"\n",(0,a.jsxs)(n.p,{children:["These will ",(0,a.jsx)(n.em,{children:"probably"}),", although not ",(0,a.jsx)(n.em,{children:"necessarily"}),", be the players on the bench. That leaves us with at least 79.6/11 = 7.24 million per player for the starting 11, as opposed to 100/15 = 6.67 million per player when the budget is shared equally between the whole squad."]}),"\n",(0,a.jsx)(n.p,{children:"But now that we know a bit more about the code, I think it's pretty obvious that the majority of the improvements will need to be made in the points prediction module. So how can we more accurately predict the points each player will score each week? By outsourcing the work to a neural network!"}),"\n",(0,a.jsx)(n.h2,{children:"PyTorch"}),"\n",(0,a.jsxs)(n.p,{children:["Enter PyTorch. ",(0,a.jsx)(n.a,{href:"https://pytorch.org/",children:"PyTorch"})," is an open source machine learning framework developed by Facebook. Machine learning works by analysing a set of training data to build a model of the data that can then be used to make future predictions. This sort of problem is particularly suited to machine learning as it can recognise patterns humans might miss."]}),"\n",(0,a.jsx)(n.p,{children:"First, we define the model class."}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"from torch.nn import BatchNorm1d, Dropout, Embedding, Linear, Module, ModuleList, ReLU, Sequential\nimport torch\n\nclass Model(Module):\n def __init__(self, embedding_size, num_numerical_cols):\n super().__init__()\n self.all_embeddings = ModuleList([Embedding(ni, nf) for ni, nf in embedding_size])\n self.embedding_dropout = Dropout(0.1)\n self.batch_norm_num = BatchNorm1d(num_numerical_cols)\n\n all_layers = []\n num_categorical_cols = sum((nf for ni, nf in embedding_size))\n layer_size = num_categorical_cols + num_numerical_cols\n\n layer_sizes = [100, 50, 25]\n for i, new_layer_size in enumerate(layer_sizes):\n all_layers.append(Linear(layer_size, new_layer_size))\n all_layers.append(ReLU(inplace=True))\n all_layers.append(BatchNorm1d(new_layer_size))\n all_layers.append(Dropout(0.2))\n layer_size = new_layer_size\n\n all_layers.append(Linear(layer_sizes[-1], 1))\n self.layers = Sequential(*all_layers)\n\n def forward(self, x_categorical, x_numerical):\n embeddings = []\n for i, e in enumerate(self.all_embeddings):\n embeddings.append(e(x_categorical[:,i]))\n x = torch.cat(embeddings, 1)\n x = self.embedding_dropout(x)\n x_numerical = self.batch_norm_num(x_numerical)\n x = torch.cat([x, x_numerical], 1)\n x = self.layers(x)\n return x\n"})}),"\n",(0,a.jsxs)(n.p,{children:["In basic terms, we're taking the input data and applying a set of transformations. These are known as layers. The constructor sets up the layers which can then be used by the ",(0,a.jsx)(n.code,{children:"forward"})," function. This ",(0,a.jsx)(n.code,{children:"forward"})," function is effectively what is invoked when training the model. You can find lots of information on the different layer types ",(0,a.jsx)(n.a,{href:"https://pytorch.org/docs/stable/nn.html",children:"in the docs"}),"."]}),"\n",(0,a.jsxs)(n.p,{children:["One layer that's pretty interesting (and actually understandable) is the Dropout layer. ",(0,a.jsx)(n.a,{href:"https://jmlr.org/papers/v15/srivastava14a.html",children:"Neural networks are generally prone to overfitting the training data"}),". The idea of a dropout layer is to randomly remove some neurons in the network during training to prevent these neurons from coadaptation. ",(0,a.jsx)(n.a,{href:"https://medium.com/@lipeng2/dropout-is-so-important-e517bbe3ffcc",children:"Here's an excellent article about this by Michael Peng over on medium"}),"."]}),"\n",(0,a.jsxs)(n.p,{children:["Anyway, back to the transformation layers. At the start we don't know exactly what values these transformations should have. We define a ",(0,a.jsx)(n.a,{href:"https://en.wikipedia.org/wiki/Loss_function",children:"loss function"})," that tells us how far away our predicted value is from the real thing, and each time the model runs against the training data it attempts to minimise the loss function. This is known as a ",(0,a.jsx)(n.strong,{children:"training loop"})," or ",(0,a.jsx)(n.strong,{children:"epoch"}),"."]}),"\n",(0,a.jsx)(n.pre,{children:(0,a.jsx)(n.code,{className:"language-python",children:"# train the model\ndef train_model():\n logger.info('Training the model. This could take a long time...')\n model.train()\n # define the optimization\n loss_function = MSELoss()\n optimizer = SGD(model.parameters(), lr=0.001, momentum=0.9)\n epochs = 500\n # enumerate epochs\n for epoch in range(epochs):\n # compute the model output\n predictions = model(categorical_training_data, numerical_training_data).squeeze()\n # calculate loss compared to actual outputs\n loss = loss_function(predictions, training_outputs)\n logger.info('Epoch: {}/{}. Loss: {:.2f}'.format(epoch+1, epochs, loss))\n # clear the gradients\n optimizer.zero_grad()\n # credit assignment\n loss.backward()\n # update model weights\n optimizer.step()\n"})}),"\n",(0,a.jsxs)(n.p,{children:["Finally we need data in order to train the model. Huge thanks to ",(0,a.jsx)(n.a,{href:"https://github.com/vaastav",children:"vaastav"})," who has been scraping data since the 2016/2017 season over on the ",(0,a.jsx)(n.a,{href:"https://github.com/vaastav/Fantasy-Premier-League",children:"Fantasy-Premier-League"})," project."]}),"\n",(0,a.jsxs)(n.p,{children:["Maybe you've noticed I've skipped over ",(0,a.jsx)(n.strong,{children:"a lot"})," of details. I'm not a data scientist. I can't tell you what layers to use, how to pick the number of neurons in each layer, why to pick a certain learning rate or how many epochs are required because I honestly don't know. The numbers above are purely the result of trial and error."]}),"\n",(0,a.jsx)(n.h2,{children:"Results"}),"\n",(0,a.jsxs)("figure",{children:[(0,a.jsx)("img",{src:"/assets/blog/mr-robot/team-final.webp",alt:"Final team selection"}),(0,a.jsx)("figcaption",{children:(0,a.jsx)(n.p,{children:"The final team selection and their point totals after the first gameweek."})})]}),"\n",(0,a.jsxs)(n.p,{children:["Here's the team and their point totals for week 1. This gave an overall gameweek score of 82, which puts the team at rank 2,004,686 out of ~7 million active players. Definitely not mindblowing, but a promising start nonetheless. Fantasy football is all about consistency. If the team consistently scores 82 that would be well over 3000 points by the end of the season. ",(0,a.jsx)(n.a,{href:"https://www.reddit.com/r/FantasyPL/comments/bowc35/points_needed_to_win_fpl/",children:"Easily enough to win the entire competition"}),"."]}),"\n",(0,a.jsxs)(n.p,{children:["Earlier we talked about patterns and how machine learning can recognise things we might miss. One thing I definitely missed was that ",(0,a.jsx)(n.a,{href:"https://www.liverpoolfc.com/news/first-team/440750-mohamed-salah-could-set-premier-league-opening-day-record",children:"Mo Salah has scored in the opening game of the previous 4 seasons"}),". This season, Salah ended up being the second highest points scorer in the opening gameweek."]}),"\n",(0,a.jsxs)(n.p,{children:["I'll try to remember to write an update post at the end of the season. In the mean time, you can ",(0,a.jsx)(n.a,{href:"https://github.com/ashharrison90/fantasy_pl",children:"check out all the code here."})]})]})}n.default=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return(0,a.jsx)(o,Object.assign({},e,{children:(0,a.jsx)(l,e)}))}},1151:function(e,n,t){"use strict";t.d(n,{a:function(){return s}});var a=t(7294);let r=a.createContext({});function s(e){let n=a.useContext(r);return a.useMemo(function(){return"function"==typeof e?e(n):{...n,...e}},[n,e])}}},function(e){e.O(0,[325,514,381,42,774,888,179],function(){return e(e.s=2607)}),_N_E=e.O()}]); \ No newline at end of file diff --git a/about.html b/about.html index 20f16ec3..e4c8df65 100644 --- a/about.html +++ b/about.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/index.html b/index.html index 2b77d013..5a5ebb58 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/posts.html b/posts.html index 4878f4bb..664c2344 100644 --- a/posts.html +++ b/posts.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/posts/betterer.html b/posts/betterer.html index e27440dd..14f2e377 100644 --- a/posts/betterer.html +++ b/posts/betterer.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/posts/building-this-site.html b/posts/building-this-site.html index 28781603..1ac6d005 100644 --- a/posts/building-this-site.html +++ b/posts/building-this-site.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/posts/bye-bye-popups.html b/posts/bye-bye-popups.html index a5ef3281..0126890c 100644 --- a/posts/bye-bye-popups.html +++ b/posts/bye-bye-popups.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/posts/lighthouse.html b/posts/lighthouse.html index 580ed870..55356a5e 100644 --- a/posts/lighthouse.html +++ b/posts/lighthouse.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/posts/mr-robot.html b/posts/mr-robot.html index 0c90d120..80b795e0 100644 --- a/posts/mr-robot.html +++ b/posts/mr-robot.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/posts/wordle-poem-1.html b/posts/wordle-poem-1.html index b6332cfb..31d1b05e 100644 --- a/posts/wordle-poem-1.html +++ b/posts/wordle-poem-1.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file