From e24f8cce424d007f9a70ff503c1d410b0bf6a70c Mon Sep 17 00:00:00 2001 From: Terencio Agozzino Date: Sun, 1 Nov 2026 09:09:23 +0100 Subject: [PATCH] feat: count visits with GoatCounter, without cookies Page views, plus what a visitor came for: the CV or thesis downloaded, an email started, a link followed out (LinkedIn, GitHub, the papers). No cookies and no personal data, so no consent banner. Co-Authored-By: Claude Opus 5.5 --- src/layouts/Base.astro | 29 +++++++++++++++++++++++++++++ tests/site.spec.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index eebaa30..34bd1af 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -132,6 +132,35 @@ const jsonLd = { { threshold: 0.1 }, ); document.querySelectorAll('.reveal').forEach((el) => observer.observe(el)); + + // Besides page views, GoatCounter counts what a visitor came for: a PDF + // downloaded (CV, thesis), an email started, or a link followed out. + declare global { + interface Window { + goatcounter?: { count: (vars: { path: string; title?: string; event: boolean }) => void }; + } + } + document.addEventListener('click', (event) => { + const link = (event.target as Element).closest('a'); + if (!link || !window.goatcounter) return; + const url = new URL(link.href, location.href); + const path = + url.protocol === 'mailto:' + ? 'email' + : url.pathname.endsWith('.pdf') + ? `download ${url.pathname.split('/').pop()}` + : url.host !== location.host + ? `out ${url.host}${url.pathname.replace(/\/$/, '')}` + : null; + if (path) window.goatcounter.count({ path, title: link.textContent?.trim(), event: true }); + }); + + diff --git a/tests/site.spec.ts b/tests/site.spec.ts index ae7f748..c314db7 100644 --- a/tests/site.spec.ts +++ b/tests/site.spec.ts @@ -187,6 +187,36 @@ test('shows the name and the button in the navigation only once scrolled past', await expect(button).toBeVisible(); }); +test('counts the visit, and the CV, the thesis, an email and a link out', async ({ page }) => { + // A stand-in for GoatCounter's script, which records what it is given. + await page.route('https://gc.zgo.at/count.js', (route) => + route.fulfill({ + contentType: 'text/javascript', + body: `window.counted = [location.pathname]; + window.goatcounter = { count: (vars) => window.counted.push(vars.path) };`, + }), + ); + // Clicks are counted, not followed. + await page.route(/\.pdf$|linkedin\.com/, (route) => route.abort()); + await page.goto('/'); + await page.waitForFunction(() => 'goatcounter' in window); + + await page.evaluate(() => document.addEventListener('click', (event) => event.preventDefault())); + await page.locator('#top a[download]').click(); + await page.locator(`#experience a[href$="master-thesis.pdf"]`).click(); + await page.locator('#contact a[href^="mailto:"]').click(); + await page.locator('#contact a[href*="linkedin.com"]').click(); + await page.locator('#top a[href="#contact"]').click(); + + expect(await page.evaluate(() => (window as unknown as { counted: string[] }).counted)).toEqual([ + '/', + 'download terencio-agozzino-cv.pdf', + 'download terencio-agozzino-master-thesis.pdf', + 'email', + 'out www.linkedin.com/in/terencioagozzino', + ]); +}); + test.describe('without JavaScript', () => { test.use({ javaScriptEnabled: false });