Skip to content
83 linesCodeBlameRaw
1/**
2 * Whether a visitor has agreed to site analytics, where the law asks
3 * first: the EU and EEA, the UK and Switzerland. The server decides from
4 * the visitor's country and marks the page (`<meta name="g1t-analytics"
5 * content="consent">`); the choice is kept in this browser's local
6 * storage. Safe to import on the server, where nothing is chosen.
7 */
8
9/** Countries whose visitors are asked before analytics runs. */
10const ASK_FIRST = new Set([
11 // The EU.
12 "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU",
13 "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE",
14 // The rest of the EEA, the UK and Switzerland.
15 "IS", "LI", "NO", "GB", "CH",
16]);
17
18/** Whether a visitor from `country` (ISO 3166 alpha-2, as Cloudflare gives it) is asked first. */
19export function asksFirst(country: string | null | undefined): boolean {
20 return !!country && ASK_FIRST.has(country.toUpperCase());
21}
22
23/**
24 * Whether the visitor making `request` is asked first. Cloudflare sets the
25 * country header (replacing any the visitor sent); `cf` is the same answer.
26 */
27export function visitorAsksFirst(request: Request): boolean {
28 return asksFirst(request.headers.get("cf-ipcountry") ?? (request as Request & { cf?: { country?: string } }).cf?.country);
29}
30
31/** The installation analytics runs on; anywhere else nothing is sent or asked. */
32export const ANALYTICS_HOST = "g1t.sh";
33
34export function analyticsHere(): boolean {
35 return typeof window !== "undefined" && window.location.hostname === ANALYTICS_HOST;
36}
37
38/** Whether this page was marked as one whose visitor is asked first. */
39export function consentRequired(): boolean {
40 return typeof document !== "undefined" && document.querySelector('meta[name="g1t-analytics"][content="consent"]') !== null;
41}
42
43export type Choice = "yes" | "no" | null;
44
45const KEY = "g1t_analytics";
46let current: Choice | undefined;
47const listeners = new Set<() => void>();
48
49/** What this browser chose; null until it has. */
50export function choice(): Choice {
51 if (typeof window === "undefined") return null;
52 if (current === undefined) {
53 try {
54 const stored = window.localStorage.getItem(KEY);
55 current = stored === "yes" || stored === "no" ? stored : null;
56 } catch {
57 current = null;
58 }
59 }
60 return current;
61}
62
63/** Record a choice, or null to ask again. */
64export function choose(next: Choice) {
65 current = next;
66 try {
67 if (next) window.localStorage.setItem(KEY, next);
68 else window.localStorage.removeItem(KEY);
69 } catch {
70 // No storage: the choice holds for this page only.
71 }
72 for (const listener of listeners) listener();
73}
74
75export function onChoice(listener: () => void): () => void {
76 listeners.add(listener);
77 return () => listeners.delete(listener);
78}
79
80/** Whether analytics may run in this browser now. */
81export function allowed(): boolean {
82 return analyticsHere() && (!consentRequired() || choice() === "yes");
83}