diff --git a/tests/test_site_nav.py b/tests/test_site_nav.py
index fd66f61fd..f445b6765 100644
--- a/tests/test_site_nav.py
+++ b/tests/test_site_nav.py
@@ -427,7 +427,7 @@ START_HERE = [
"/retrobios/", "/retrobios/wiki/what-are-bios-files/", "/retrobios/which-pack/",
"/retrobios/wiki/first-install/", "/retrobios/wiki/troubleshooting/",
"/retrobios/wiki/faq/", "/retrobios/wiki/legal/", "/retrobios/wiki/glossary/",
- "/retrobios/platforms/",
+ "/retrobios/wiki/privacy/", "/retrobios/platforms/",
]
diff --git a/tests/test_site_privacy.py b/tests/test_site_privacy.py
new file mode 100644
index 000000000..a478c836b
--- /dev/null
+++ b/tests/test_site_privacy.py
@@ -0,0 +1,79 @@
+"""What the site counts stays what wiki/privacy.md says it counts.
+
+The counter is website/src/scripts/count.ts. These checks read its source:
+the guarantees are in a few lines that a later edit could drop without any
+page looking different.
+"""
+from __future__ import annotations
+
+import re
+import unittest
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+COUNTER = REPO_ROOT / "website" / "src" / "scripts" / "count.ts"
+FOOTER = REPO_ROOT / "website" / "src" / "components" / "Footer.astro"
+PRIVACY = REPO_ROOT / "wiki" / "privacy.md"
+
+
+class TheCounterAsksNothingOfTheDevice(unittest.TestCase):
+ def setUp(self) -> None:
+ self.source = COUNTER.read_text(encoding="utf-8")
+ # Comments state the rules: only the code must keep them.
+ self.code = re.sub(r"^\s*//.*$", "", self.source, flags=re.M)
+
+ def test_no_storage_on_the_device(self) -> None:
+ for api in ("document.cookie", "localStorage", "sessionStorage", "indexedDB"):
+ with self.subTest(api=api):
+ self.assertNotIn(api, self.code)
+
+ def test_a_declined_browser_sends_nothing(self) -> None:
+ self.assertIn("doNotTrack === '1'", self.code)
+ self.assertIn("globalPrivacyControl === true", self.code)
+ self.assertRegex(self.code, r"const send = .*\n\tif \(!enabled\) return;")
+
+ def test_only_the_published_site_counts(self) -> None:
+ self.assertIn("location.hostname === HOST", self.code)
+
+ def test_no_query_anchor_or_device_detail_is_read(self) -> None:
+ for read in ("location.search", "location.hash", "location.href,", "screen.", "navigator.language"):
+ with self.subTest(read=read):
+ self.assertNotIn(read, self.code)
+
+ def test_the_referrer_is_reduced_to_its_domain(self) -> None:
+ self.assertIn("from.origin", self.code)
+ self.assertNotIn("from.pathname", self.code)
+
+ def test_a_search_with_personal_details_is_not_sent(self) -> None:
+ self.assertIn("!query.includes('@')", self.code)
+ self.assertIn("\\d{7,}", self.code)
+ self.assertIn("message.startsWith('No results for')", self.code)
+
+ def test_one_endpoint(self) -> None:
+ self.assertEqual(set(re.findall(r"https://[\w.-]+", self.code)),
+ {"https://retrobios.goatcounter.com"})
+
+
+class ThePageSaysWhatIsCounted(unittest.TestCase):
+ def test_every_page_loads_the_counter_and_links_the_page(self) -> None:
+ footer = FOOTER.read_text(encoding="utf-8")
+ self.assertIn("import '../scripts/count.ts';", footer)
+ self.assertIn("wiki/privacy/", footer)
+ # Hidden until the script shows it: a declined browser is not asked.
+ self.assertRegex(footer, r"data-rb-helpful-box hidden")
+
+ def test_the_page_names_each_thing_sent(self) -> None:
+ page = PRIVACY.read_text(encoding="utf-8")
+ for said in ("No cookies", "Do Not Track", "Global Privacy Control",
+ "copy button", "found nothing", "Did this page help?",
+ "The installer sends nothing back", "retrobios.goatcounter.com"):
+ with self.subTest(said=said):
+ self.assertIn(said, page)
+
+ def test_the_installer_has_no_counter(self) -> None:
+ installer = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
+ self.assertNotIn("goatcounter", installer.lower())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/website/src/components/Footer.astro b/website/src/components/Footer.astro
index c70aae7ea..ba5c39970 100644
--- a/website/src/components/Footer.astro
+++ b/website/src/components/Footer.astro
@@ -1,7 +1,8 @@
---
// Starlight's footer (edit link, previous and next), who keeps the project
// and where to report a problem, the release the packs come from, and the
-// licence line. The release is read from release.json, written when the
+// licence line, and "Did this page help?" (src/scripts/count.ts, shown
+// only where the answer is counted). The release is read from release.json, written when the
// packs were published: the site may be newer than the packs. None of it
// is in the search index: "MIT for the tooling" was the excerpt of short
// pages, and "bios", "release" and "problem" matched every page.
@@ -24,9 +25,16 @@ const date = releaseDate(tag);
+
+
diff --git a/website/src/scripts/count.ts b/website/src/scripts/count.ts
new file mode 100644
index 000000000..a401fc8d6
--- /dev/null
+++ b/website/src/scripts/count.ts
@@ -0,0 +1,152 @@
+// What the site counts, as wiki/privacy.md states it. No cookie, no storage
+// on the device, no identifier: one request per page or action to
+// GoatCounter, which keeps totals. Nothing is sent when the browser asks
+// not to be tracked (Do Not Track, Global Privacy Control), from a local
+// build, or from a page not served at the site's address.
+//
+// Sent: the page path (never its query or #anchor), the domain of the site
+// the reader came from, and the name of an action. An action is a click on
+// a copy button, a pack or file download, a link that leaves the site, a
+// site search that found nothing (the words, when they cannot hold a
+// person's details), and the answer to "Did this page help?".
+
+const ENDPOINT = 'https://retrobios.goatcounter.com/count';
+const HOST = 'abdess.github.io';
+const BASE = import.meta.env.BASE_URL.replace(/\/?$/, '/');
+
+const declined = (): boolean => {
+ const nav = navigator as Navigator & { globalPrivacyControl?: boolean };
+ return nav.doNotTrack === '1' || nav.globalPrivacyControl === true;
+};
+const enabled = location.hostname === HOST && !declined();
+
+// The path under the site, "/emulators/mgba/": the base names every page.
+const pagePath = (): string => {
+ const path = location.pathname;
+ return path.startsWith(BASE) ? `/${path.slice(BASE.length)}` : path;
+};
+
+const send = (params: Record): void => {
+ if (!enabled) return;
+ const url = `${ENDPOINT}?${new URLSearchParams({ ...params, rnd: Math.random().toString(36).slice(2) })}`;
+ if (!navigator.sendBeacon?.(url)) new Image().src = url;
+};
+
+// An action, filed under its name with the page it happened on as title.
+export const countEvent = (name: string): void => {
+ send({ p: name.slice(0, 120), t: pagePath(), e: 'true' });
+};
+
+// The site the reader came from, by domain alone: a path can carry a
+// search or a private thread.
+const referrer = (): string => {
+ try {
+ const from = new URL(document.referrer);
+ return from.hostname && from.hostname !== location.hostname ? from.origin : '';
+ } catch {
+ return '';
+ }
+};
+
+const countPage = (): void => {
+ const params: Record = { p: pagePath() };
+ const from = referrer();
+ if (from) params.r = from;
+ // GitHub Pages serves 404.html at the address asked for: the path says
+ // which link is broken, the title that it was one.
+ if (document.title.startsWith('Page not found')) params.t = '404';
+ send(params);
+};
+
+// Clicks
+
+const slug = (text: string): string => text.trim().toLowerCase().replace(/[^a-z0-9.+-]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
+
+const linkAction = (link: HTMLAnchorElement): string | undefined => {
+ let url: URL;
+ try {
+ url = new URL(link.href, location.href);
+ } catch {
+ return undefined;
+ }
+ const release = /\/releases\/download\/[^/]+\/([^/?#]+)$/.exec(url.pathname);
+ if (url.hostname === 'github.com' && release) return `download/${decodeURIComponent(release[1])}`;
+ if (url.hostname === 'raw.githubusercontent.com') {
+ return `file/${decodeURIComponent(url.pathname.split('/').pop() ?? '')}`;
+ }
+ if (url.hostname === location.hostname) {
+ const get = new RegExp(`^${BASE}get/([^/]+)/`).exec(url.pathname);
+ return get ? `get/${get[1]}` : undefined;
+ }
+ return url.protocol.startsWith('http') ? `out/${url.hostname}` : undefined;
+};
+
+const onClick = (event: MouseEvent): void => {
+ const target = event.target instanceof Element ? event.target : null;
+ if (!target) return;
+ const copy = target.closest('.expressive-code .copy button, [data-copy]');
+ if (copy) {
+ const frame = copy.closest('.frame');
+ const title = frame?.querySelector('.title')?.textContent ?? '';
+ countEvent(`copy/${slug(title) || (copy.matches('[data-copy]') ? 'picker' : 'code')}`);
+ return;
+ }
+ const help = target.closest('[data-rb-helpful]');
+ if (help) {
+ countEvent(`helpful/${help.dataset.rbHelpful}`);
+ return;
+ }
+ const link = target.closest('a[href]');
+ const action = link && linkAction(link);
+ if (action) countEvent(action);
+};
+
+// Search
+
+// A search that found nothing names a missing page. Its words are sent
+// only when they cannot hold a person's details: short, no "@", no long
+// run of digits. A hash is a file asked for, and is kept.
+const isHash = (query: string): boolean => /^[0-9a-f]{8}$|^[0-9a-f]{32}$|^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(query);
+const sendable = (query: string): boolean =>
+ isHash(query) || (query.length >= 2 && query.length <= 40 && !query.includes('@') && !/\d{7,}/.test(query));
+
+const watchSearch = (): void => {
+ const root = document.getElementById('starlight__search');
+ if (!root) return;
+ const sent = new Set();
+ let timer: number | undefined;
+ const settle = (): void => {
+ const input = root.querySelector('.pagefind-ui__search-input');
+ const query = (input?.value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
+ // Pagefind's English message once a search has ended with nothing;
+ // while it runs the message reads "Searching for ...".
+ const message = root.querySelector('.pagefind-ui__message')?.textContent ?? '';
+ if (!query || !message.startsWith('No results for') || sent.has(query) || !sendable(query)) return;
+ sent.add(query);
+ countEvent(`search-none/${query}`);
+ };
+ new MutationObserver(() => {
+ window.clearTimeout(timer);
+ timer = window.setTimeout(settle, 1500);
+ }).observe(root, { childList: true, subtree: true, characterData: true });
+};
+
+// "Did this page help?": the buttons answer once, then say thanks.
+const wireHelpful = (): void => {
+ // Shown only where an answer is sent: a box that counts nothing would ask for nothing.
+ if (!enabled) return;
+ for (const box of document.querySelectorAll('[data-rb-helpful-box]')) {
+ box.hidden = false;
+ box.addEventListener('click', (event) => {
+ if (!(event.target instanceof Element) || !event.target.closest('[data-rb-helpful]')) return;
+ for (const button of box.querySelectorAll('button')) button.remove();
+ const thanks = box.querySelector('[data-rb-helpful-thanks]');
+ if (thanks) thanks.hidden = false;
+ });
+ }
+};
+
+countPage();
+document.addEventListener('click', onClick, { capture: true });
+watchSearch();
+wireHelpful();
diff --git a/wiki/privacy.md b/wiki/privacy.md
new file mode 100644
index 000000000..6f0854844
--- /dev/null
+++ b/wiki/privacy.md
@@ -0,0 +1,50 @@
+---
+level: start
+order: 65
+terms: privacy, cookies, analytics, tracking
+---
+
+# What we count
+
+No cookies. No account. Nothing is stored on your device, and nothing
+links a visit to a person.
+
+## On this site
+
+When a page opens, your browser sends one request to
+[GoatCounter](https://www.goatcounter.com), an open-source counter. It
+carries:
+
+- the page address, without the part after `?` or `#`;
+- the site you came from, by its domain only (`google.com`, `reddit.com`).
+
+GoatCounter reads the browser name and the country from that request. It does
+not keep your IP address. To tell a reload from a second visitor it keeps a
+code made from the request for 8 hours, then drops it.
+
+These actions are counted the same way, with the page they happened on:
+
+- a click on a copy button, a pack download or a file link;
+- a site search that found nothing, with its words, so a missing page shows up.
+ Words with an `@` or a long number are never sent;
+- the answer to "Did this page help?".
+
+Not sent: your screen size, your language, a search that found results.
+
+The totals are kept two years and are public:
+[retrobios.goatcounter.com](https://retrobios.goatcounter.com).
+
+If your browser sends Do Not Track or Global Privacy Control, nothing is sent,
+and the "Did this page help?" question is not shown. A content blocker that
+stops GoatCounter changes nothing on the site.
+
+## The installer
+
+The installer sends nothing back. It downloads files from GitHub, which sees
+those requests as it sees any download.
+
+## Search engines
+
+Google and Bing tell us which searches showed the site and how often it was
+clicked. They report totals, from their own records, never from your
+browser.