Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

112 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",
13 "BE",
14 "BG",
15 "HR",
16 "CY",
17 "CZ",
18 "DK",
19 "EE",
20 "FI",
21 "FR",
22 "DE",
23 "GR",
24 "HU",
25 "IE",
26 "IT",
27 "LV",
28 "LT",
29 "LU",
30 "MT",
31 "NL",
32 "PL",
33 "PT",
34 "RO",
35 "SK",
36 "SI",
37 "ES",
38 "SE",
39 // The rest of the EEA, the UK and Switzerland.
40 "IS",
41 "LI",
42 "NO",
43 "GB",
44 "CH",
45]);
46
47/** Whether a visitor from `country` (ISO 3166 alpha-2, as Cloudflare gives it) is asked first. */
48export function asksFirst(country: string | null | undefined): boolean {
49 return !!country && ASK_FIRST.has(country.toUpperCase());
50}
51
52/**
53 * Whether the visitor making `request` is asked first. Cloudflare sets the
54 * country header (replacing any the visitor sent); `cf` is the same answer.
55 */
56export function visitorAsksFirst(request: Request): boolean {
57 return asksFirst(request.headers.get("cf-ipcountry") ?? (request as Request & { cf?: { country?: string } }).cf?.country);
58}
59
60/** The installation analytics runs on; anywhere else nothing is sent or asked. */
61export const ANALYTICS_HOST = "g1t.sh";
62
63export function analyticsHere(): boolean {
64 return typeof window !== "undefined" && window.location.hostname === ANALYTICS_HOST;
65}
66
67/** Whether this page was marked as one whose visitor is asked first. */
68export function consentRequired(): boolean {
69 return typeof document !== "undefined" && document.querySelector('meta[name="g1t-analytics"][content="consent"]') !== null;
70}
71
72export type Choice = "yes" | "no" | null;
73
74const KEY = "g1t_analytics";
75let current: Choice | undefined;
76const listeners = new Set<() => void>();
77
78/** What this browser chose; null until it has. */
79export function choice(): Choice {
80 if (typeof window === "undefined") return null;
81 if (current === undefined) {
82 try {
83 const stored = window.localStorage.getItem(KEY);
84 current = stored === "yes" || stored === "no" ? stored : null;
85 } catch {
86 current = null;
87 }
88 }
89 return current;
90}
91
92/** Record a choice, or null to ask again. */
93export function choose(next: Choice) {
94 current = next;
95 try {
96 if (next) window.localStorage.setItem(KEY, next);
97 else window.localStorage.removeItem(KEY);
98 } catch {
99 // No storage: the choice holds for this page only.
100 }
101 for (const listener of listeners) listener();
102}
103
104export function onChoice(listener: () => void): () => void {
105 listeners.add(listener);
106 return () => listeners.delete(listener);
107}
108
109/** Whether analytics may run in this browser now. */
110export function allowed(): boolean {
111 return analyticsHere() && (!consentRequired() || choice() === "yes");
112}