Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: display core web vitals results #3

Merged
merged 11 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 42 additions & 127 deletions app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,29 @@
<div class="flex flex-col justify-start mt-4 sm:mt-8 md:my-12 p-4 gap-8 md:gap-12 flex-grow max-w-full">
<h1 class="flex flex-row gap-4 text-white text-3xl md:text-5xl">
<span class="text-green-400">&raquo;</span>
<button v-if="domain && !editing" class="bg-transparent" @click="enableEditing">{{ domain }}</button>
<form v-else class="flex flex-col gap-4 overflow-hidden" @submit.prevent="navigateToNewDomain">
<input ref="input" v-model="newDomain" name="domain" type="text"
class="md:-mt-1 rounded-none py-0 bg-transparent outline-none border-b-2 border-b-solid border-transparent focus:border-green-500 underline-dashed"
autofocus inputmode="url" autocapitalize="none" placeholder="Enter a domain" required />
<button type="submit"
class="bg-green-400 text-black hover: hover:bg-white focus:bg-white active:bg-white text-xl md:text-2xl py-2 px-6 md:self-start">See
results</button>
</form>
<TheDomainForm v-model:domain="domain" v-model:editing="editing" />
</h1>
<template v-if="!editing && domain">
<div v-if="status === 'pending' || results"
class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.performance : undefined"
caption="performance" />
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.accessibility : undefined"
caption="accessibility" />
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.bestPractices : undefined"
caption="best practices" />
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.seo : undefined" caption="SEO" />
</div>
<template v-if="status === 'pending' || results">
<div class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
<CoreWebVitals v-if="status === 'pending' || results?.crux" :pass="results?.crux?.cwv"
:lcp="results?.crux?.lcp" :cls="results?.crux?.cls" :inp="results?.crux?.inp"
:loading="status === 'pending'" size="normal" show-p75 />
<template v-else-if="results">
<ProgressRing size="normal" :value="results.lighthouse.performance" caption="performance" />
<ProgressRing size="normal" :value="results.lighthouse.accessibility" caption="accessibility" />
<ProgressRing size="normal" :value="results.lighthouse.bestPractices" caption="best practices" />
<ProgressRing size="normal" :value="results.lighthouse.seo" caption="SEO" />
</template>
</div>
<LighthouseTable v-if="status === 'pending' || (results && results.crux)" :loading="status === 'pending'"
v-bind="results?.lighthouse || {}" />
</template>
<div v-else-if="domain">
No results could be fetched. Is it a valid domain?
</div>
<div class="flex flex-col gap-2 mt-auto md:mt-8">
<NuxtLink type="submit"
class="bg-green-400 text-black hover: hover:bg-white focus:bg-white active:bg-white text-xl md:text-2xl py-2 px-6 md:self-start mb-8"
:href="shareLink" @click.prevent="nativeShare">
Share results
</NuxtLink>
<a :href="`https://pagespeed.web.dev/analysis?url=https://${domain}`"
class="self-start underline text-gray-400 hover:text-green-400 focus:text-green-400 active:text-green-400">
See full results on PageSpeed Insights &raquo;
</a>
<span v-if="results?.timestamp" class="text-gray-400">
Last updated at
<NuxtTime :datetime="results.timestamp" dateStyle="full" timeStyle="medium" />.
</span>
</div>
<TheShareLink v-if="results" :domain="domain" :type="results.crux ? 'crux' : 'pagespeed-insights'"
:timestamp="results.crux?.timestamp || results.lighthouse.timestamp" />
</template>
</div>
<footer class="mt-auto p-3 text-gray-400">
Expand All @@ -58,7 +42,7 @@

<script lang="ts" setup>
import '@unocss/reset/tailwind-compat.css'
import { joinURL, withoutLeadingSlash, parseURL } from 'ufo'
import { joinURL, withoutLeadingSlash } from 'ufo'

const route = useRoute()
const domain = computed(() => withoutLeadingSlash(route.path).toLowerCase().replace(/(\/|\?).*$/, '').trim())
Expand All @@ -69,75 +53,26 @@ if (domain.value && !/^[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/g.test(domain.value)) {
}

const editing = ref(!domain.value)
const newDomain = ref('')
const input = ref<HTMLInputElement>()

function enableEditing () {
newDomain.value = domain.value
editing.value = true
watch(input, (input) => {
if (input) {
input.focus()
input.setSelectionRange(0, newDomain.value.length)
}
}, { once: true })
}

const { data: results, status, refresh } = await useFetch(() => `/api/run/${domain.value}`, {
const { data: results, status, refresh } = await useAsyncData(async () => {
const [lighthouse, crux] = await Promise.all([
$fetch(`/api/run/${domain.value}`),
$fetch(`/api/crux/${domain.value}`).catch(() => null)
])
return { lighthouse, crux }
}, {
watch: [domain],
immediate: !!domain.value,
})

if (!domain.value) {
watch(domain, () => refresh(), { once: true })
}

const favicon = computed(() => {
const radius = 80
const stroke = 14
const normalizedRadius = radius - stroke * 2
const circumference = normalizedRadius * 2 * Math.PI

const value = status.value === 'pending' || (domain.value && !results.value)
? undefined
: (results.value ? results.value.performance : 100)
const color = !value ? '#6b7280' : value >= 90 ? '#23c55e' : value >= 50 ? '#fbbf24' : '#ef4444'
const svg = `<svg xmlns="http://www.w3.org/2000/svg" height="${radius * 2}" width="${radius * 2}">
<style>@keyframes spin {
from {transform: rotate(0deg)}
to {transform: rotate(360deg)}
}</style>
<circle
stroke="${color}"
fill="transparent"
stroke-linecap="round"
stroke-dasharray="${circumference + ' ' + circumference}"
style="transform-origin:center;stroke-dashoffset:${circumference - (Math.floor((value || 85) / 4) * 4) / 100 * circumference};transform:rotate(270deg)${!value ? ';animation:spin 1s linear infinite' : ''}"
stroke-width="${stroke}"
r="${normalizedRadius}"
cx="${radius}"
cy="${radius}"
/>
<circle
fill="${color}"
stroke-width="${stroke}"
r="${normalizedRadius - 35}"
cx="${radius}"
cy="${radius}"
/>
</svg>`
return `data:image/svg+xml;base64,${btoa(svg)}`
})
useFavicon(() => status.value !== 'pending' && !!domain.value && (results.value ? results.value.lighthouse.performance : 100))

useHead({
title: () => domain.value ? domain.value : 'page-speed.dev',
link: [
() => ({
key: 'favicon',
rel: 'icon',
type: 'image/svg',
href: favicon.value
})
]
})

useServerHead({
Expand Down Expand Up @@ -186,49 +121,29 @@ useServerSeoMeta({
if (!domain.value) {
defineOgImageComponent('Home')
useServerSeoMeta({
description: 'See and share PageSpeed Insights results simply and easily.',
description: 'See and share Core Web Vitals and Page Speed Insights results simply and easily.',
})
} else if (results.value) {
useServerSeoMeta({
description:
`Performance: ${results.value?.performance} | ` +
`Accessibility: ${results.value?.accessibility} | ` +
`Best Practices: ${results.value?.bestPractices} | ` +
`SEO: ${results.value?.seo}`
results.value?.crux
?
`Core Web Vitals: ${results.value?.crux.cwv ? 'pass' : 'fail'} | ` +
`LCP: ${results.value?.crux.lcp.caption} | ` +
`CLS: ${results.value?.crux.cls.caption} | ` +
`INP: ${results.value?.crux.inp.caption}`
:
`Performance: ${results.value?.lighthouse.performance} | ` +
`Accessibility: ${results.value?.lighthouse.accessibility} | ` +
`Best Practices: ${results.value?.lighthouse.bestPractices} | ` +
`SEO: ${results.value?.lighthouse.seo}`
})

defineOgImageComponent('Lighthouse', {
performance: results.value?.performance,
accessibility: results.value?.accessibility,
bestPractices: results.value?.bestPractices,
seo: results.value?.seo,
lighthouse: results.value.lighthouse,
crux: results.value?.crux,
domain: domain.value,
})
}

function navigateToNewDomain () {
if (!newDomain.value) { return }

const host = parseURL(newDomain.value).host || newDomain.value
editing.value = false
return navigateTo('/' + withoutLeadingSlash(host).toLowerCase().replace(/(\/|\?).*$/, '').trim())
}

const shareLink = computed(() => domain.value ? `https://twitter.com/intent/tweet?text=${encodeURIComponent(`Check out the Page Speed results for ${domain.value.replace(/\./g, '.​')}` + `\n\nhttps://page-speed.dev/${domain.value}`)}` : 'See and share PageSpeed Insights results simply and easily.')

async function nativeShare () {
try {
if (navigator.share) {
return await navigator.share({
title: 'page-speed.dev',
text: `See page speed results for ${domain.value.replace(/\./g, '.​')}`,
url: canonicalURL.value,
})
}
} catch {
// ignore errors sharing to native share and fall back directly to Twitter
}
return await navigateTo(shareLink.value, { external: true, open: { target: '_blank' } })
}

</script>
78 changes: 78 additions & 0 deletions components/Histogram.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<script setup lang="ts">
const props = defineProps({
value: {
type: Object as () => {
caption?: string | number
segments: number[]
},
required: false
},
caption: {
type: String,
required: false
},
size: {
type: String as () => 'large' | 'normal',
default: 'large'
},
showP75: {
type: Boolean,
default: false
}
})

const radius = props.size === 'large' ? 132 : 75
const stroke = props.size === 'large' ? 14 : 9
const normalizedRadius = radius - stroke * 2
const circumference = normalizedRadius * 2 * Math.PI

const colours = [
'#ef4444',
'#fbbf24',
'#23c55e'
]

const p75Color = computed(() => {
let count = 0
for (const [index, segment] of props.value?.segments?.entries() || []) {
count += segment
if (count >= 75) {
return colours[colours.length - index - 1]
}
}
return '#6b7280'
})
</script>

<template>
<span class="flex flex-col items-center" :class="size === 'large' ? 'gap-10' : 'gap-4'">
<span class="relative rounded-full flex items-center justify-center" :class="[size === 'large' ? 'text-7xl h-60 w-60' : 'text-3xl h-36 w-36']">
<svg class="absolute -right-0 -bottom-0" :height="radius * 2" :width="radius * 2" :class="{ 'animate-spin': !value }">
<circle
v-for="segment, index of [...value?.segments || [80]].reverse()"
:stroke="value ? colours[index] : '#6b7280'"
fill="transparent"
class="transform-origin-center"
:stroke-dasharray="circumference + ' ' + circumference"
:style="{
strokeDashoffset: circumference - segment / 100 * circumference,
transform: `rotate(270deg)`,
}"
:stroke-width="stroke"
:r="normalizedRadius"
:cx="radius"
:cy="radius"
/>
<circle v-if="value && showP75" :fill="p75Color" stroke="#fff" stroke-width="5" cx="20" cy="20" r="10" style="transform: translateX(-2px) translateY(calc(50% - 20px))"></circle>
</svg>
<slot>
<span v-if="value?.caption" class="flex flex-row items-baseline gap-1">
<span :class="size === 'large' ? 'text-6xl' : 'text-3xl'">{{ value.caption.replace(/m?s/, '') }}</span>
<span v-if="value.caption.match(/m?s/)" :class="size === 'large' ? 'text-4xl' : 'text-lg'">{{ value.caption.match(/m?s/)![0] }}</span>
</span>
</slot>
</span>
<span :class="size === 'large' ? 'text-4xl' : 'text-2xl'">{{ caption }}</span>
</span>
</template>

44 changes: 24 additions & 20 deletions components/OgImage/Lighthouse.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,39 @@ defineProps({
type: String,
required: true,
},
performance: {
type: Number,
required: true,
},
accessibility: {
type: Number,
required: true,
lighthouse: {
type: Object,
required: true
},
bestPractices: {
type: Number,
required: true,
},
seo: {
type: Number,
required: true,
crux: {
type: Object
},
})
</script>

<template>
<div class="h-full w-full flex items-start justify-start border-solid bg-[#212121] text-white">
<div class="flex flex-col items-center justify-between h-full w-full">
<div class="flex flex-row justify-center gap-12 pt-12 w-full">
<ProgressRing :value="performance" caption="performance" />
<ProgressRing :value="accessibility" caption="accessibility" />
<ProgressRing :value="bestPractices" caption="best practices" />
<ProgressRing :value="seo" caption="SEO" />
<div class="flex flex-col justify-center gap-8 pt-12 pb-6 w-full">
<div class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
<template v-if="crux">
<CoreWebVitals
:pass="crux.cwv"
:lcp="crux.lcp"
:cls="crux.cls"
:inp="crux.inp"
/>
</template>
<template v-else-if="lighthouse">
<ProgressRing :value="lighthouse.performance" caption="performance" />
<ProgressRing :value="lighthouse.accessibility" caption="accessibility" />
<ProgressRing :value="lighthouse.bestPractices" caption="best practices" />
<ProgressRing :value="lighthouse.seo" caption="SEO" />
</template>
</div>
<LighthouseTable v-if="crux" v-bind="lighthouse" class="text-2xl" />
</div>
<div class="flex flex-row gap-4 self-start text-white text-5xl pl-16 pb-24">
<div class="flex flex-row gap-4 self-end text-white text-5xl pr-8 pt-4 pb-24">
<span class="text-green-400">&raquo;</span> {{ domain }}
</div>
</div>
Expand Down
Loading
Loading